""" blender_author_joggers.py (T-1089 wave 2, joggers_modern + sweatpant family) Authors JOGGERS/sweatpants as per-body offset shells: hips + full legs, tapered toward the ankle into snug cuff bands, painted waist drawstring, and a track SIDE STRIPE carried as its own tint region. Reuses blender_author_offset_shell.py (via blender_author_denim_pants.py's module instance) for scene build / join / solidify / export, and reuses the denim companion's REQUIRED lower-body practices directly (not re-derived): * boundary WELD of coincident segment-seam rings before offsetting (denim.weld_boundaries) — un-welded rings offset apart along diverging normals and open cracks at the waist/knee joins. * open-rim FLATTENING (denim.flatten_open_rims) — the segment splitter leaves 4.5-7.6 cm jagged teeth at the waist/ankle rims; boundary verts are pulled onto clean planes pre-offset. The flattened ankle plane IS the jogger cuff hem. * waist flare + post-solidify residue clamp (denim.waist_flare, denim.clamp_waist_residue) — deep-crouch waist-fold clip mitigation. * per-body leg-axis landmarks (denim.LegLandmarks) — thigh_l/calf_l control points drive azimuth math for the side stripe and the cuff/band rib paint, so every parameter derives from the body's own bones (Q-060 per-body mode). What this companion adds, as reusable parameters (not hacks): * TAPERED variable offset — constant baggy standoff (--offset) above the knee, smoothstep taper to --mid-offset at the cuff top, then a short feathered step down to the snug --cuff-offset inside the cuff band: the sweatpant silhouette (baggy thigh -> tapered shin -> gripping cuff). * ankle CUFF band (--cuff-frac of the garment span) — its own mask region (A channel) with painted knit RIB (azimuth-arc alternating shades, constant metric rib width via the per-z leg axis). * SIDE STRIPE (--stripe-width metres, scaled by hip width) — constant-width azimuth band along the outer leg from waistband to cuff, painted bright AND masked to its own region (B channel) for independent tinting. * painted elastic waistband (subtle vertical rib) + centre-front DRAWSTRING: two eyelets and two hanging cords with a slight outward slant (albedo only; dark cords stay legible under the luma-scaled region tint). Regions (RGBA mask, toon_garment.gdshader): waistband -> R (tint_0), legs -> G (tint_1), side stripe -> B (tint_2), ankle cuffs -> A (tint_3). A parked logo_uv TEXCOORD_1 layer is authored (denim.author_parked_uv2) — not logo-capable, but the shader samples UV2 unconditionally. Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r. Natural boundaries give the waist opening and ankle hems for free. Per-body mode only (offset shells author per body, Q-060). Usage (joggers_modern reference invocation): tooling/blender --background --python \ tooling/garment-fit/blender_author_joggers.py -- \ client/assets/characters/bodies \ client/assets/characters/clothing/joggers_modern \ [--bodies average_m,child,...] [--offset 0.014] [--mid-offset 0.010] \ [--cuff-offset 0.0065] [--band-frac 0.055] [--cuff-frac 0.075] \ [--stripe-width 0.034] [--fabric 0.55,0.56,0.58] \ [--stripe-rgb 0.88,0.89,0.91] [--waist-flare 0.007] [--seed N] [--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 denim companion as a module — it carries the shared lower-body # practices (weld, rim flatten, waist flare, clamp, landmarks, parked UV2) and # its own instance of the base offset-shell library. # -------------------------------------------------------------------------- _HERE = os.path.dirname(os.path.abspath(__file__)) _spec = importlib.util.spec_from_file_location( "denim_pants", os.path.join(_HERE, "blender_author_denim_pants.py")) denim = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(denim) base = denim.base # single shared base-module instance log = base.log FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base) # -------------------------------------------------------------------------- # Parameters (defaults = joggers_modern) # -------------------------------------------------------------------------- COVERED_SEGMENTS = [ "seg_hips", "seg_leg_upper_l", "seg_leg_upper_r", "seg_leg_lower_l", "seg_leg_lower_r", ] TEX_SIZE = 1024 # albedo + mask resolution (rib + cords need >512) WAIST_FLARE_M = 0.007 # denim-proven deep-crouch waist-fold mitigation # Tapered-offset profile (metres). OFFSET_HI_M = 0.014 # hips/thigh standoff — baggy sweatpant volume OFFSET_MID_M = 0.010 # taper target at the cuff top (shin) OFFSET_CUFF_M = 0.0065 # snug cuff standoff (ankles barely deform) CUFF_STEP_W_M = 0.010 # feather width of the taper->cuff step # Vertical proportions — fractions of the garment span (waist_z - hem_z). BAND_FRAC = 0.055 # elastic waistband height (R region) CUFF_FRAC = 0.075 # knit cuff band height (A region) — taller than a hem SEAM_W_FRAC = 0.008 # painted border-stitch line width CORD_DROP_FRAC = 0.050 # drawstring cord length below the waistband CORD_SLANT = 0.18 # outward cord slant (dx per unit hang depth) # Horizontal / arc-metric proportions (scaled by hip half-width ratio lm.sh). STRIPE_W_M = 0.034 # side stripe metric width (B region) RIB_PERIOD_M = 0.009 # cuff knit-rib period (arc metres) BAND_RIB_PERIOD_M = 0.006 # waistband elastic-rib period (arc metres) CORD_X_M = 0.016 # drawstring eyelet/cord |x| at the band CORD_W_M = 0.005 # cord width EYELET_R_M = 0.0045 # eyelet dot radius # Fleece style (sRGB floats; flat toon-friendly, texture carries identity). FLEECE_RGB = (0.55, 0.56, 0.58) # heather grey, luma ~0.56 (tint-faithful) STRIPE_RGB = (0.88, 0.89, 0.91) # near-white track stripe CORD_RGB = (0.17, 0.17, 0.19) # charcoal drawstring ALBEDO_NOISE = 0.020 # +/- fleece jitter BAND_SHADE = 0.94 # waistband overall darkening CUFF_SHADE = 0.96 # cuff overall darkening RIB_LO = 0.86 # cuff rib dark-stripe factor BAND_RIB_LO = 0.94 # waistband rib dark-stripe factor STITCH_SHADE = 0.80 # band/cuff border-stitch darkening PLAIN = False # --plain: skip drawstring/rib/stitch paint NOISE_SEED = 2090 # distinct from denim (2089) # -------------------------------------------------------------------------- # Joggers landmark finalization (denim.LegLandmarks carries the leg axis; the # band/cuff fractions here are jogger parameters, not denim's) # -------------------------------------------------------------------------- def finalize_jogger_landmarks(lm, waist_plane, ankle_plane, band_frac, cuff_frac): lm.waist_z = waist_plane lm.hem_z = ankle_plane lm.span = waist_plane - ankle_plane lm.sh = lm.hip_x / denim._REF_HIP_X lm.band_h = band_frac * lm.span lm.cuff_h = cuff_frac * lm.span lm.seam_w = SEAM_W_FRAC * lm.span lm.band_z = waist_plane - lm.band_h lm.cuff_top = ankle_plane + lm.cuff_h log(f"landmarks: waist={lm.waist_z:.3f} hem={lm.hem_z:.3f} " f"span={lm.span:.3f} hip_x={lm.hip_x:.3f} band_z={lm.band_z:.3f} " f"cuff_top={lm.cuff_top:.3f} sh={lm.sh:.2f}") # -------------------------------------------------------------------------- # Tapered offset — the jogger silhouette # -------------------------------------------------------------------------- def _smoothstep(u): u = min(max(u, 0.0), 1.0) return u * u * (3.0 - 2.0 * u) def tapered_offset(shell, knee_z, cuff_top, off_hi, off_mid, off_cuff, step_w): """Per-vertex outward offset along smoothed normals: `off_hi` above the knee, smoothstep taper to `off_mid` at the cuff top, then a feathered step down to the snug `off_cuff` inside the cuff band. The step reads as the cuff ledge; the A-region tint + painted rib carry the rest.""" me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.normal_update() denom = max(knee_z - cuff_top, 1e-6) lo = hi = None for v in bm.verts: z = v.co.z t = off_hi + (off_mid - off_hi) * _smoothstep((knee_z - z) / denom) s = _smoothstep((cuff_top + step_w - z) / step_w) t = t + (off_cuff - t) * s v.co += v.normal * t lo = t if lo is None else min(lo, t) hi = t if hi is None else max(hi, t) bm.to_mesh(me) bm.free() me.update() log(f"tapered offset: {hi * 1000:.1f} mm (thigh) -> {lo * 1000:.1f} mm " f"(cuff); knee_z={knee_z:.3f} cuff_top={cuff_top:.3f}") # -------------------------------------------------------------------------- # Jogger feature field (texel-level; drives albedo AND mask together) # -------------------------------------------------------------------------- def _jogger_field(px, py, pz, lm): """Evaluate jogger features at texel 3D positions (numpy arrays). Returns (front, in_band, in_cuff, stripe, arc): `stripe` feeds the mask B channel, `in_band`/`in_cuff` feed R/A, `arc` is the azimuth arc-length coordinate around the per-z leg axis used by the rib paint. """ front = py * FRONT_Y_SIGN > 0.004 in_band = pz >= lm.band_z in_cuff = pz <= lm.cuff_top mid = (~in_band) & (~in_cuff) # Per-z leg axis, mirrored by x sign (denim.LegLandmarks control points). 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 # Side stripe: azimuth toward the outer (+/-x) direction; constant metric # width via arc distance (same construction as the denim outseam). arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r stripe = mid & (arc_out < 0.5 * STRIPE_W_M * lm.sh) # Rib coordinate: signed azimuth arc length (0 at the outseam; the +/-pi # wrap sits at the inner ankle / body centre where it cannot be seen). arc = np.arctan2(dy, dx * side) * r return front, in_band, in_cuff, stripe, arc def _paint_texels(px, py, pz, noise, lm): """Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions.""" n = px.shape[0] front, in_band, in_cuff, stripe, arc = _jogger_field(px, py, pz, lm) # --- albedo ------------------------------------------------------------ alb = np.empty((n, 4), dtype=np.float32) for c in range(3): alb[:, c] = FLEECE_RGB[c] + noise alb[:, 3] = 1.0 # Side stripe: bright, low-noise (crisp edge carries the sport read). for c in range(3): alb[stripe, c] = STRIPE_RGB[c] + 0.5 * noise[stripe] if not PLAIN: # Cuff knit rib: alternating shade stripes, constant metric width. rib_dark = (np.floor(arc / (RIB_PERIOD_M * lm.sh)).astype(np.int64) % 2) == 0 cuff_f = np.where(rib_dark, RIB_LO, 1.0) * CUFF_SHADE alb[in_cuff, :3] *= cuff_f[in_cuff, None] # Waistband elastic rib: same construction, subtler. band_dark = (np.floor(arc / (BAND_RIB_PERIOD_M * lm.sh)) .astype(np.int64) % 2) == 0 band_f = np.where(band_dark, BAND_RIB_LO, 1.0) * BAND_SHADE alb[in_band, :3] *= band_f[in_band, None] # Border stitching: darker lines under the band and above the cuff. w2 = lm.seam_w * 0.5 stitch = (np.abs(pz - lm.band_z) < w2) | (np.abs(pz - lm.cuff_top) < w2) alb[stitch, :3] *= STITCH_SHADE # Drawstring: two eyelets on the band + two slanted hanging cords. ex = CORD_X_M * lm.sh eyelet_z = lm.band_z + 0.35 * lm.band_h eyelets = front & ( np.hypot(np.abs(px) - ex, pz - eyelet_z) < EYELET_R_M * lm.sh) drop = CORD_DROP_FRAC * lm.span hang = np.clip(eyelet_z - pz, 0.0, None) cord_cx = ex + CORD_SLANT * hang cords = front & (pz > eyelet_z - drop) & (pz <= eyelet_z) \ & (np.abs(np.abs(px) - cord_cx) < 0.5 * CORD_W_M * lm.sh) for c in range(3): alb[eyelets | cords, c] = CORD_RGB[c] else: alb[in_cuff, :3] *= CUFF_SHADE alb[in_band, :3] *= BAND_SHADE # --- region mask: band R / legs G / stripe B / cuffs A ------------------- mask = np.zeros((n, 4), dtype=np.float32) is_b = stripe & ~in_band & ~in_cuff is_g = ~(in_band | in_cuff | is_b) mask[in_band, 0] = 1.0 mask[is_g, 1] = 1.0 mask[is_b, 2] = 1.0 mask[in_cuff, 3] = 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 jogger field, write albedo + mask together (same rasterizer construction as the denim companion; the paint field is what differs).""" 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] = FLEECE_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"jogger_albedo_{body}", albedo_path) _save(mask_buf, f"jogger_mask_{body}", mask_path) log(f"saved albedo -> {albedo_path}") log(f"saved mask -> {mask_path}") return albedo_img # -------------------------------------------------------------------------- # Per-body authoring # -------------------------------------------------------------------------- def author_jogger_shell(body_dir, out_dir, body): 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) # Full-length hem AT the ankle joint (calf tail); rim teeth flattened onto # clean planes pre-offset (denim practice — no jagged silhouette). ankle_plane = float(lm.leg_z_pts[0]) waist_plane, ankle_plane = denim.flatten_open_rims(shell, ankle_plane) span = waist_plane - ankle_plane knee_z = float(lm.leg_z_pts[1]) # calf head cuff_top = ankle_plane + CUFF_FRAC * span tapered_offset(shell, knee_z, cuff_top, OFFSET_HI_M, OFFSET_MID_M, OFFSET_CUFF_M, CUFF_STEP_W_M) denim.waist_flare(shell, waist_plane, BAND_FRAC * span, WAIST_FLARE_M) base.solidify(shell, base.CLOTH_THICKNESS_M) denim.clamp_waist_residue(shell, waist_plane) # Landmarks reference the CLEAN rims for the paint/mask pass. finalize_jogger_landmarks(lm, waist_plane, ankle_plane, BAND_FRAC, CUFF_FRAC) 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 OFFSET_HI_M, OFFSET_MID_M, OFFSET_CUFF_M, BAND_FRAC, CUFF_FRAC global STRIPE_W_M, FLEECE_RGB, STRIPE_RGB, WAIST_FLARE_M, NOISE_SEED, PLAIN argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if len(argv) < 2: print("Usage: -- [--bodies a,b,c] " "[--offset M] [--mid-offset M] [--cuff-offset M] " "[--band-frac F] [--cuff-frac F] [--stripe-width M] " "[--fabric r,g,b] [--stripe-rgb r,g,b] [--waist-flare M] " "[--seed N] [--plain]") sys.exit(1) bodies_root = argv[0] out_dir = argv[1] if "--offset" in argv: OFFSET_HI_M = float(argv[argv.index("--offset") + 1]) if "--mid-offset" in argv: OFFSET_MID_M = float(argv[argv.index("--mid-offset") + 1]) if "--cuff-offset" in argv: OFFSET_CUFF_M = float(argv[argv.index("--cuff-offset") + 1]) if "--band-frac" in argv: BAND_FRAC = float(argv[argv.index("--band-frac") + 1]) if "--cuff-frac" in argv: CUFF_FRAC = float(argv[argv.index("--cuff-frac") + 1]) if "--stripe-width" in argv: STRIPE_W_M = float(argv[argv.index("--stripe-width") + 1]) if "--fabric" in argv: FLEECE_RGB = tuple( float(v) for v in argv[argv.index("--fabric") + 1].split(",")) if "--stripe-rgb" in argv: STRIPE_RGB = tuple( float(v) for v in argv[argv.index("--stripe-rgb") + 1].split(",")) if "--waist-flare" in argv: WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1]) if "--seed" in argv: NOISE_SEED = int(argv[argv.index("--seed") + 1]) 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"joggers per-body mode: {len(bodies)} bodies, offset " f"{OFFSET_HI_M * 1000:.0f}->{OFFSET_CUFF_M * 1000:.1f} mm, " f"band_frac={BAND_FRAC} cuff_frac={CUFF_FRAC} " f"stripe={STRIPE_W_M * 1000:.0f} mm, 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_jogger_shell(body_dir, out_dir, body) 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()