""" blender_author_cargo_pants.py (T-1089 wave 2, cargo_pants) Authors full-length CARGO pants as per-body offset shells. Reuses blender_author_offset_shell.py as the base library (scene build, join, offset, solidify, GLB export) and blender_author_denim_pants.py as the proven bottoms companion (boundary WELD of coincident segment-seam rings, open-rim FLATTENING onto clean planes, per-body LegLandmarks, waist flare, hem cut, parked logo UV2) — the denim script's utilities are imported as a module, not re-derived. What cargo adds, as reusable parameters: * --straight: STRAIGHT-FIT boost — extra radial stand-off away from the per-z leg axis, ramping 0 at the knee to the full value at the ankle, so the lower leg does not taper with the calf (straight silhouette; also buys lower-leg clip clearance). Applied post-offset, pre-solidify. * TEXEL-level cargo feature painting (same one-field-drives-both design as denim: painted albedo and region mask always agree): - albedo: large outer-thigh CARGO POCKETS with button-down FLAPS (filled panels, border stitching, centre pleat, two flap buttons), outseam/inseam side seams, centre-front fly stitch, belt loops + waist button, waistband and hem border stitching — flat tone-on-tone utility look, identity carried by the texture (style pin: modern only). - mask: waistband -> R, legs -> G, pockets + flaps -> B (spec: waistband=R, legs=G, pockets+flaps=B). A SIGNED azimuth arc around the per-z leg axis (0 at the outer side, positive toward the front) places the pockets slightly forward-of-side and the flap buttons symmetrically — the same analytic-field approach as the denim script, generalised from unsigned to signed arc. * pocket proportions anchor to each body's own THIGH span (crotch -> knee) and hip half-width, so the pockets stay proportional across all 11 bodies (child included) — the base.derive_thresholds philosophy. Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r; natural boundaries give the waist opening and ankle hems. Regions are painted, not modelled — no 3D pocket geometry (texture carries identity). Usage (cargo_pants reference invocation): tooling/blender --background --python \ tooling/garment-fit/blender_author_cargo_pants.py -- \ client/assets/characters/bodies \ client/assets/characters/clothing/cargo_pants \ [--bodies average_m,child,...] [--offset 0.013] [--hem-frac 1.0] \ [--waist-flare 0.007] [--straight 0.010] [--base-rgb 0.36,0.37,0.29] \ [--plain] Writes per body: /.glb (skinned, albedo embedded) /_mask.png (RGBA region mask, UV0) /_base_albedo.png Plus: /base_albedo.png (average_m's, shared sidecar) /reference_mask.png (average_m's, runtime fallback) Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe), Q-060 (per-body offset shells). """ import importlib.util import os import shutil import sys import bmesh import bpy import numpy as np # -------------------------------------------------------------------------- # Import the base offset-shell module + the denim bottoms companion # (weld / rim-flatten / flare / landmarks utilities live there) # -------------------------------------------------------------------------- _HERE = os.path.dirname(os.path.abspath(__file__)) def _load(name, fname): spec = importlib.util.spec_from_file_location( name, os.path.join(_HERE, fname)) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod base = _load("offset_shell_base", "blender_author_offset_shell.py") denim = _load("denim_pants_lib", "blender_author_denim_pants.py") log = base.log FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base) # -------------------------------------------------------------------------- # Parameters # -------------------------------------------------------------------------- COVERED_SEGMENTS = [ "seg_hips", "seg_leg_upper_l", "seg_leg_upper_r", "seg_leg_lower_l", "seg_leg_lower_r", ] HEM_FRAC = 1.0 # 1.0 = full leg to the ankle OFFSET_M = 0.013 # cargo sits a touch looser than jeans (0.012) STRAIGHT_M = 0.010 # straight-fit radial boost at the ankle (0 at knee) WAIST_FLARE_M = 0.007 # waistband rim stand-off (deep-crouch mitigation, # proven on jeans) TEX_SIZE = 1024 # painted pockets/seams need >512 # Vertical proportions — fractions of the garment span (waist_z - hem_z), # via denim.LegLandmarks.finalize (BAND/CUFF/SEAM fractions proven on jeans). # Pocket proportions — fractions of the THIGH span (crotch_z - knee_z). FLAP_TOP_FRAC = 0.18 # flap top below the crotch FLAP_H_FRAC = 0.14 # flap height POCKET_BOT_FRAC = 0.74 # pocket bottom below the crotch # Horizontal proportions — signed azimuth ARC around the per-z leg axis # (metres on the reference body, scaled by the body's hip ratio lm.sh). POCKET_HALF_ARC_M = 0.058 # pocket half-width FLAP_EXTRA_ARC_M = 0.007 # flap overhangs the pocket by this much per side POCKET_FWD_BIAS_M = 0.010 # pocket centre sits slightly forward of the outseam BUTTON_R_M = 0.0075 # flap button radius LOOP_X_FRACS = (0.55, 1.30) # belt-loop |x| positions (fractions of hip_x) LOOP_W_M = 0.016 # belt loop width (m, scaled by hip ratio) # Utility olive/grey style (sRGB floats; flat toon-friendly, tone-on-tone). CARGO_RGB = (0.360, 0.370, 0.290) # utility olive-grey THREAD_RGB = (0.225, 0.235, 0.185) # tone-on-tone darker stitching BUTTON_RGB = (0.170, 0.160, 0.140) # matte button ALBEDO_NOISE = 0.020 # +/- woven jitter POCKET_SHADE = 0.93 # pocket panel albedo darkening FLAP_SHADE = 0.85 # flap panel albedo darkening LOOP_SHADE = 0.80 # belt-loop albedo darkening PLAIN = False # --plain: skip pockets/stitch/button paint NOISE_SEED = 3089 # -------------------------------------------------------------------------- # Geometry: straight-fit boost (cargo's reusable fit parameter) # -------------------------------------------------------------------------- def straight_boost(shell, lm, boost): """Extra RADIAL stand-off away from the per-z leg axis below the knee, ramping linearly from 0 at the knee to `boost` at the ankle. The body calf tapers; pushing the shell out progressively keeps the pant leg straight (cargo silhouette) instead of hugging the calf. Applied post-offset, pre-solidify; weights/UVs are untouched.""" if boost <= 0.0: return knee_z = float(lm.leg_z_pts[1]) ankle_z = float(lm.leg_z_pts[0]) span = max(knee_z - ankle_z, 1e-6) bm = bmesh.new() bm.from_mesh(shell.data) n = 0 for v in bm.verts: z = v.co.z if z >= knee_z: continue t = min((knee_z - z) / span, 1.0) side = 1.0 if v.co.x >= 0.0 else -1.0 cx = side * float(np.interp(z, lm.leg_z_pts, lm.leg_x_pts)) cy = float(np.interp(z, lm.leg_z_pts, lm.leg_y_pts)) dx = v.co.x - cx dy = v.co.y - cy r = (dx * dx + dy * dy) ** 0.5 if r > 1e-6: v.co.x += boost * t * dx / r v.co.y += boost * t * dy / r n += 1 bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"straight-fit boost: {n} verts, +{boost * 1000:.1f} mm radial at " f"ankle (ramped from knee z={knee_z:.3f})") # -------------------------------------------------------------------------- # Cargo feature field (texel-level; drives albedo AND mask together) # -------------------------------------------------------------------------- def _cargo_field(px, py, pz, lm): """Evaluate cargo features at texel 3D positions (numpy arrays). Returns a dict of bool arrays. `pocket`/`flap` feed the mask B channel; the stitch/fill features feed the albedo only. """ w2 = lm.seam_w * 0.5 front = py * FRONT_Y_SIGN > 0.004 in_band = pz >= lm.band_z mid = (~in_band) & (pz > lm.hem_z + 0.5 * lm.cuff_h) # Per-z leg axis, mirrored by x sign; SIGNED azimuth arc around it # (0 at the outer side, positive toward the front, +/-pi*r at the inseam). side = np.where(px >= 0.0, 1.0, -1.0) cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts) cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts) dx = px - cx dy = py - cy r = np.hypot(dx, dy) + 1e-9 theta = np.arctan2(dy * FRONT_Y_SIGN / r, np.clip(dx * side / r, -1.0, 1.0)) sarc = theta * r # signed arc distance from the outer direction # Side seams: outseam on the outer azimuth; inseam opposite, below crotch. outseam = mid & (np.abs(sarc) < w2) arc_in = (np.pi - np.abs(theta)) * r inseam = mid & (arc_in < w2) \ & (pz < lm.crotch_z - 0.01 * lm.span / denim._REF_SPAN) # Centre-front fly stitch (slightly off-centre, like the jeans J-front). fly = front & mid & (np.abs(px - 0.012 * lm.sh) < w2) \ & (pz > lm.crotch_z + 0.015 * lm.span / denim._REF_SPAN) # --- cargo pocket + flap (outer thigh, slightly forward-of-side) -------- knee_z = float(lm.leg_z_pts[1]) thigh = max(lm.crotch_z - knee_z, 1e-6) flap_top = lm.crotch_z - FLAP_TOP_FRAC * thigh flap_bot = flap_top - FLAP_H_FRAC * thigh pocket_bot = lm.crotch_z - POCKET_BOT_FRAC * thigh p_arc = POCKET_HALF_ARC_M * lm.sh f_arc = p_arc + FLAP_EXTRA_ARC_M * lm.sh a = sarc - POCKET_FWD_BIAS_M * lm.sh # pocket-centred signed arc pocket = (np.abs(a) < p_arc) & (pz <= flap_top) & (pz >= pocket_bot) flap = (np.abs(a) < f_arc) & (pz <= flap_top) & (pz >= flap_bot) # Pocket border stitching (sides + bottom), centre pleat, flap edge. p_side = (np.abs(np.abs(a) - p_arc) < w2) & (pz <= flap_top) \ & (pz >= pocket_bot) p_bottom = (np.abs(pz - pocket_bot) < w2) & (np.abs(a) < p_arc) pleat = (np.abs(a) < w2) & (pz < flap_bot - 2.0 * w2) & (pz >= pocket_bot) f_edge = flap & ((np.abs(np.abs(a) - f_arc) < w2) | (np.abs(pz - flap_bot) < w2)) pkt_stitch = p_side | p_bottom | pleat | f_edge # Two flap buttons, symmetric about the pocket centre. btn_r = BUTTON_R_M * lm.sh btn_z = flap_bot + 0.30 * (flap_top - flap_bot) buttons = np.zeros_like(front) for bx in (-0.45 * f_arc, 0.45 * f_arc): buttons |= np.hypot(a - bx, pz - btn_z) < btn_r # Border stitching: waistband seam + hem stitch. wstitch = np.abs(pz - lm.band_z) < w2 hemstitch = np.abs(pz - lm.cuff_top) < w2 return { "front": front, "in_band": in_band, "seams": outseam | inseam | fly, "wstitch": wstitch, "hemstitch": hemstitch, "pocket": pocket, "flap": flap, "pkt_stitch": pkt_stitch, "buttons": buttons, } def _paint_texels(px, py, pz, noise, lm): """Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions.""" n = px.shape[0] f = _cargo_field(px, py, pz, lm) # --- albedo ------------------------------------------------------------- alb = np.empty((n, 4), dtype=np.float32) for c in range(3): alb[:, c] = CARGO_RGB[c] + noise alb[:, 3] = 1.0 if not PLAIN: # Belt loops: darkened bands on the waistband. loop_w = LOOP_W_M * lm.sh loops = np.zeros(n, dtype=bool) for fx in LOOP_X_FRACS: loops |= np.abs(np.abs(px) - fx * lm.hip_x) < loop_w * 0.5 loops |= (~f["front"]) & (np.abs(px) < loop_w * 0.5) # centre-back loops &= f["in_band"] alb[loops, :3] *= LOOP_SHADE # Panel fills first, stitch lines on top. alb[f["pocket"] & ~f["flap"], :3] *= POCKET_SHADE alb[f["flap"], :3] *= FLAP_SHADE thread = f["seams"] | f["wstitch"] | f["hemstitch"] | f["pkt_stitch"] alb[thread, 0] = THREAD_RGB[0] alb[thread, 1] = THREAD_RGB[1] alb[thread, 2] = THREAD_RGB[2] # Flap buttons + waist button. btn = f["buttons"] | (f["front"] & ( np.hypot(px, pz - (lm.band_z + 0.5 * lm.band_h)) < 0.009 * lm.sh)) alb[btn, 0] = BUTTON_RGB[0] alb[btn, 1] = BUTTON_RGB[1] alb[btn, 2] = BUTTON_RGB[2] # --- region mask: waistband R / legs G / pockets+flaps B ----------------- mask = np.zeros((n, 4), dtype=np.float32) is_b = (f["pocket"] | f["flap"]) & ~f["in_band"] is_g = ~(f["in_band"] | is_b) mask[f["in_band"], 0] = 1.0 mask[is_b, 2] = 1.0 mask[is_g, 1] = 1.0 return alb, mask def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H): """Barycentric texel fill of one UV triangle: interpolate 3D positions, evaluate the cargo field, write albedo + mask together (same rasterizer contract as the denim companion, pointed at the cargo painter).""" a, b, c = uvs A, B, C = cos 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] pxg = xs + 0.5 pyg = ys + 0.5 w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom w2 = 1.0 - w0 - w1 inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4) if not inside.any(): return w0i, w1i, w2i = w0[inside], w1[inside], w2[inside] px3 = w0i * A.x + w1i * B.x + w2i * C.x py3 = w0i * A.y + w1i * B.y + w2i * C.y pz3 = w0i * A.z + w1i * B.z + w2i * C.z ysin = ys[inside] xsin = xs[inside] alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm) alb_buf[ysin, xsin] = alb mask_buf[ysin, xsin] = mask def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body): """Rasterize all UV0 triangles once, producing the painted albedo and the region mask from one shared feature-field evaluation per texel.""" W = H = TEX_SIZE rng = np.random.default_rng(NOISE_SEED) noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE) alb_buf = np.empty((H, W, 4), dtype=np.float32) for c in range(3): alb_buf[:, :, c] = CARGO_RGB[c] + noise_buf alb_buf[:, :, 3] = 1.0 mask_buf = np.zeros((H, W, 4), dtype=np.float32) mask_buf[:, :, 1] = 1.0 # background = legs green (bleed-safe) me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() if not len(bm.loops.layers.uv): raise RuntimeError("no UV layer for albedo/mask paint") uv_layer = bm.loops.layers.uv[0] tri_count = 0 for face in bm.faces: loops = face.loops[:] uvs = [loop[uv_layer].uv.copy() for loop in loops] cos = [loop.vert.co.copy() for loop in loops] for i in range(1, len(uvs) - 1): _raster_tri_paint( alb_buf, mask_buf, noise_buf, (uvs[0], uvs[i], uvs[i + 1]), (cos[0], cos[i], cos[i + 1]), lm, W, H) tri_count += 1 bm.free() log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})") def _save(buf, name, path): img = bpy.data.images.new(name, W, H, alpha=True) img.pixels.foreach_set(buf.reshape(-1)) img.update() img.filepath_raw = path img.file_format = 'PNG' img.save() return img albedo_img = _save(alb_buf, f"cargo_albedo_{body}", albedo_path) _save(mask_buf, f"cargo_mask_{body}", mask_path) log(f"saved albedo -> {albedo_path}") log(f"saved mask -> {mask_path}") return albedo_img # -------------------------------------------------------------------------- # Per-body authoring # -------------------------------------------------------------------------- def author_cargo_shell(body_dir, out_dir, body, offset, hem_frac): crotch_z, _hips_top = denim.probe_hips_bounds(body_dir) base.clear_scene() base.COVERED_SEGMENTS = COVERED_SEGMENTS shell, armature = base.build_covered_mesh(body_dir) denim.weld_boundaries(shell) zs = [v.co.z for v in shell.data.vertices] waist_z, ankle_z = max(zs), min(zs) lm = denim.LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005) hem_z = denim.hem_cut(shell, lm, hem_frac) ankle_plane = float(lm.leg_z_pts[0]) if hem_frac >= 0.999 else hem_z waist_plane, ankle_plane = denim.flatten_open_rims(shell, ankle_plane) base.offset_outward(shell, offset) straight_boost(shell, lm, STRAIGHT_M) denim.waist_flare(shell, waist_plane, denim.BAND_FRAC * (waist_plane - ankle_plane), WAIST_FLARE_M) base.solidify(shell, base.CLOTH_THICKNESS_M) denim.clamp_waist_residue(shell, waist_plane) # Landmarks reference the CLEAN rims (band under the flattened waist edge, # hem stitch above the flattened ankle rim). lm.waist_z = waist_plane lm.finalize(ankle_plane) denim.author_parked_uv2(shell) albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png") mask_path = os.path.join(out_dir, f"{body}_mask.png") albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body) base.assign_fabric_material(shell, albedo_img) base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb")) def main(): global HEM_FRAC, CARGO_RGB, PLAIN, WAIST_FLARE_M, STRAIGHT_M argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if len(argv) < 2: print("Usage: -- [--bodies a,b,c] " "[--offset M] [--hem-frac F] [--waist-flare M] [--straight M] " "[--base-rgb r,g,b] [--plain]") sys.exit(1) bodies_root = argv[0] out_dir = argv[1] offset = OFFSET_M if "--offset" in argv: offset = float(argv[argv.index("--offset") + 1]) if "--hem-frac" in argv: HEM_FRAC = float(argv[argv.index("--hem-frac") + 1]) if "--waist-flare" in argv: WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1]) if "--straight" in argv: STRAIGHT_M = float(argv[argv.index("--straight") + 1]) if "--base-rgb" in argv: CARGO_RGB = tuple( float(v) for v in argv[argv.index("--base-rgb") + 1].split(",")) if "--plain" in argv: PLAIN = True 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"cargo per-body mode: {len(bodies)} bodies, offset " f"{offset * 1000:.0f} mm, straight {STRAIGHT_M * 1000:.0f} mm, " f"hem-frac {HEM_FRAC}, plain={PLAIN}") 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_cargo_shell(body_dir, out_dir, body, offset, HEM_FRAC) results.append((body, "ok")) except Exception as exc: log(f"ERROR {body}: {exc}") import traceback traceback.print_exc() results.append((body, f"error: {exc}")) ref = base.REFERENCE_BODY ref_mask = os.path.join(out_dir, f"{ref}_mask.png") if os.path.isfile(ref_mask): shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png")) log(f"copied {ref}_mask.png -> reference_mask.png (fallback)") ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png") if os.path.isfile(ref_alb): shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png")) log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)") 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()