""" blender_author_swim_trunks.py (T-1089 wave 2, swim set — swim_trunks) Authors bright SWIM TRUNKS as per-body offset shells: hips + upper legs with the hem clearly ABOVE the knee (shorter than shorts_modern's 0.78 thigh fraction), a painted centre-front drawstring (knot + two hanging cords) and a lateral SIDE PANEL colour block down each leg. Regions (RGBA mask, toon_garment.gdshader): waistband -> R (tint_0), body -> G (tint_1), side panel -> B (tint_2) Companion to blender_author_offset_shell.py (imported as a library — scene build/join, offset, solidify, GLB export). The bottoms-specific practices are REUSED from the proven wave-1 companions, not rediscovered: * denim (blender_author_denim_pants.py): boundary WELD of coincident segment-seam rings before offsetting (un-welded rings offset apart along diverging normals -> cracks), open-rim FLATTENING onto clean planes (the segment splitter leaves 4.5-7.6 cm jagged teeth at the waist/hem rims), the feathered --waist-flare deep-crouch mitigation, the post-solidify waist residue clamp, and the parked logo TEXCOORD_1 layer (trunks are not logo-capable, but toon_garment.gdshader samples UV2 unconditionally). * legs (blender_author_offset_shell_legs.py): the bone-plane hem cut. Like the denim script, albedo and region mask are painted TEXEL-level from ONE analytic feature-field evaluation per texel (UV0 triangles rasterized with barycentric-interpolated 3D positions), so the painted drawstring/panel and the recolor regions always agree: - albedo: bright saturated default (coral body, teal side panel), painted drawstring knot + hanging cords (centre front), waistband border + hem border stitches, side-panel edge piping — flat and toon-friendly, identity carried by the texture (style pin: modern only). - mask: waistband R, body G, side panel B. All cut/mask/paint parameters derive PER BODY from that body's own bone landmarks (thigh_l/r) and measured mesh extents (waist rim valley, hem plane), scaled by the body's garment span and hip half-width — the same proportional-ratio philosophy as base.derive_thresholds. Per-body mode only (offset shells author per body, Q-060). Usage (swim_trunks reference invocation): tooling/blender --background --python \ tooling/garment-fit/blender_author_swim_trunks.py -- \ client/assets/characters/bodies \ client/assets/characters/clothing/swim_trunks \ [--bodies average_m,child,...] [--offset 0.012] [--hem-frac 0.60] \ [--band-frac 0.14] [--panel-frac 0.42] [--waist-flare 0.007] \ [--base-rgb 0.95,0.45,0.35] [--panel-rgb 0.13,0.68,0.64] [--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 wave-1 modules (shared machinery — reused, not copied) # -------------------------------------------------------------------------- _HERE = os.path.dirname(os.path.abspath(__file__)) def _load(mod_name, file_name): spec = importlib.util.spec_from_file_location( mod_name, os.path.join(_HERE, file_name)) 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", "blender_author_denim_pants.py") legs = _load("offset_shell_legs", "blender_author_offset_shell_legs.py") log = base.log FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base) # -------------------------------------------------------------------------- # Parameters (defaults = swim_trunks) # -------------------------------------------------------------------------- COVERED_SEGMENTS = ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"] HEM_FRAC = 0.60 # fraction of the thigh bone KEPT below its head. # 0.60 puts the hem ~17 cm above the knee on # average_m — clearly shorter than shorts_modern's # 0.78 (~9 cm above the knee), per the swim spec. TEX_SIZE = 1024 # albedo + mask resolution (painted details need >512) WAIST_FLARE_M = 0.007 # feathered radial stand-off at the waistband rim # (deep-crouch waist-fold mitigation — QA evidence # from the peasant + jeans sets, reused from denim) # Vertical proportions — fractions of the garment span (waist_z - hem_z). BAND_FRAC = 0.14 # waistband height (R region) (~4.4 cm on average_m) SEAM_W_FRAC = 0.019 # painted stitch/piping line width (~6 mm on average_m) HEM_STITCH_FRAC = 0.028 # hem border stitch centre height above the hem CORD_LEN_FRAC = 0.20 # drawstring cord drop below the knot (~6.3 cm) # Horizontal proportions — fractions of the hip half-width (|thigh head x|). PANEL_HW_FRAC = 0.42 # side-panel half ARC width (~3.8 cm -> 7.6 cm panel) # Drawstring metrics in metres on average_m, scaled by the body's hip ratio. KNOT_R_M = 0.009 # knot blob radius CORD_W_M = 0.0055 # cord line width CORD_X0_M = 0.011 # cord |x| offset just below the knot CORD_SLANT = 0.28 # outward drift of the cords per metre of drop # Swim style (sRGB floats; saved as-is — matches the proven base pipeline). # Bright saturated default per spec: coral body / teal side panel. Luma of # both stays near the toon_garment.gdshader recolor sweet spot (~0.5-0.6). CORAL_RGB = (0.95, 0.45, 0.35) # body + waistband base (luma ~0.59) TEAL_RGB = (0.13, 0.68, 0.64) # side panel (luma ~0.51) TRIM_RGB = (0.97, 0.95, 0.90) # drawstring + stitches/piping (cream) BAND_SHADE = 0.93 # waistband albedo darkening (reads untinted) ALBEDO_NOISE = 0.020 # +/- woven jitter PLAIN = False # --plain: skip drawstring/stitch/piping paint NOISE_SEED = 3089 # Reference proportions (average_m) the fractions were calibrated against: # waist rim valley 1.0312, hem(0.60) 0.7139 -> span 0.3173; |thigh head x|. _REF_HIP_X = 0.0906 # -------------------------------------------------------------------------- # Per-body landmarks # -------------------------------------------------------------------------- class TrunkLandmarks: """Cut/mask/paint parameters derived from one body's bones + mesh.""" def __init__(self, armature): bones = armature.data.bones thigh_l = bones.get("thigh_l") thigh_r = bones.get("thigh_r") if thigh_l is None or thigh_r is None: raise RuntimeError("thigh_l/thigh_r missing — not the 65-bone rig?") self.hip_x = (abs(thigh_l.head_local.x) + abs(thigh_r.head_local.x)) / 2.0 # Hem plane: fraction of the thigh bone kept below its head (averaged # over both sides — they are symmetric on every shipped body). self.hem_z = ( thigh_l.head_local.z + HEM_FRAC * (thigh_l.tail_local.z - thigh_l.head_local.z) + thigh_r.head_local.z + HEM_FRAC * (thigh_r.tail_local.z - thigh_r.head_local.z) ) / 2.0 # Leg axis control points (z-increasing: knee -> hip) for the azimuth # side-panel placement. x values are the +x (left) leg; the right leg # mirrors via sign. self.leg_z_pts = np.array([thigh_l.tail_local.z, thigh_l.head_local.z]) self.leg_x_pts = np.array( [abs(thigh_l.tail_local.x), abs(thigh_l.head_local.x)]) self.leg_y_pts = np.array([thigh_l.tail_local.y, thigh_l.head_local.y]) log(f"landmarks: hem plane z={self.hem_z:.3f} " f"(thigh head {thigh_l.head_local.z:.3f} -> knee " f"{thigh_l.tail_local.z:.3f}, keep {HEM_FRAC:.2f}) " f"hip_x={self.hip_x:.3f}") def finalize(self, waist_plane, hem_plane): """Derive paint metrics from the CLEAN (flattened) rims.""" self.waist_z = waist_plane self.hem_plane = hem_plane self.span = waist_plane - hem_plane self.sh = self.hip_x / _REF_HIP_X self.band_h = BAND_FRAC * self.span self.band_z = waist_plane - self.band_h self.seam_w = SEAM_W_FRAC * self.span self.panel_hw = PANEL_HW_FRAC * self.hip_x self.hem_stitch_z = hem_plane + HEM_STITCH_FRAC * self.span self.knot_z = self.band_z + 0.55 * self.band_h self.knot_r = KNOT_R_M * self.sh self.cord_len = CORD_LEN_FRAC * self.span self.cord_w = CORD_W_M * self.sh self.cord_x0 = CORD_X0_M * self.sh log(f"finalized: waist={self.waist_z:.3f} hem={self.hem_plane:.3f} " f"span={self.span:.3f} band_z={self.band_z:.3f} " f"panel_hw={self.panel_hw * 100:.1f}cm " f"seam_w={self.seam_w * 1000:.1f}mm knot_z={self.knot_z:.3f}") # -------------------------------------------------------------------------- # Swim feature field (texel-level; drives albedo AND mask together) # -------------------------------------------------------------------------- def _trunk_field(px, py, pz, lm): """Evaluate swim-trunk features at texel 3D positions (numpy arrays). Returns bool arrays (in_band, panel, trim): `in_band`/`panel` feed the mask R/B channels; `trim` is every painted cream detail (drawstring, border stitches, panel edge piping). """ w2 = lm.seam_w * 0.5 front = py * FRONT_Y_SIGN > 0.004 in_band = pz >= lm.band_z mid = ~in_band # Per-z leg axis, mirrored by x sign (clamps to the hip values above the # thigh head, so the panel runs straight up through the hip to the band). 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 panel: 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 panel = mid & (arc_out < lm.panel_hw) # Painted trim: panel edge piping, waistband border, hem border. piping = mid & (np.abs(arc_out - lm.panel_hw) < w2 * 0.8) wstitch = np.abs(pz - lm.band_z) < w2 * 0.7 hstitch = np.abs(pz - lm.hem_stitch_z) < w2 * 0.7 # Drawstring: knot blob + two cords hanging from it, centre front, # drifting slightly outward as they drop. knot = front & (np.hypot(px, pz - lm.knot_z) < lm.knot_r) drop = np.clip(lm.knot_z - pz, 0.0, None) cord_sep = lm.cord_x0 + CORD_SLANT * drop cords = front & (pz < lm.knot_z) & (pz > lm.knot_z - lm.cord_len) \ & (np.abs(np.abs(px) - cord_sep) < lm.cord_w * 0.5) trim = piping | wstitch | hstitch | knot | cords return in_band, panel, trim def _paint_texels(px, py, pz, noise, lm): """Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions.""" n = px.shape[0] in_band, panel, trim = _trunk_field(px, py, pz, lm) # --- albedo: coral base, shaded band, teal panel, cream trim ------------ alb = np.empty((n, 4), dtype=np.float32) for c in range(3): alb[:, c] = CORAL_RGB[c] + noise alb[:, 3] = 1.0 alb[in_band, :3] *= BAND_SHADE for c in range(3): alb[panel, c] = TEAL_RGB[c] + noise[panel] if not PLAIN: alb[trim, 0] = TRIM_RGB[0] alb[trim, 1] = TRIM_RGB[1] alb[trim, 2] = TRIM_RGB[2] np.clip(alb, 0.0, 1.0, out=alb) # --- region mask: waistband R / body G / side panel B -------------------- mask = np.zeros((n, 4), dtype=np.float32) mask[in_band, 0] = 1.0 mask[panel, 2] = 1.0 mask[~(in_band | panel), 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 swim field, write albedo + mask together (same rasterizer contract as the denim companion, driving this garment's field).""" 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] = CORAL_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 = body green (bilinear-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() total = float(W * H) r_pct = 100.0 * float((mask_buf[:, :, 0] > 0.5).sum()) / total b_pct = 100.0 * float((mask_buf[:, :, 2] > 0.5).sum()) / total log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H}); " f"mask texels: R(waistband)={r_pct:.1f}% B(panel)={b_pct:.1f}% " f"(rest G/background)") 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"swim_albedo_{body}", albedo_path) _save(mask_buf, f"swim_mask_{body}", mask_path) log(f"saved albedo -> {albedo_path}") log(f"saved mask -> {mask_path}") return albedo_img # -------------------------------------------------------------------------- # Per-body authoring # -------------------------------------------------------------------------- def author_trunks_shell(body_dir, out_dir, body, offset): base.clear_scene() base.COVERED_SEGMENTS = COVERED_SEGMENTS shell, armature = base.build_covered_mesh(body_dir) denim.weld_boundaries(shell) lm = TrunkLandmarks(armature) legs.hem_cut(shell, lm.hem_z) waist_plane, hem_plane = denim.flatten_open_rims(shell, lm.hem_z) base.offset_outward(shell, offset) denim.waist_flare(shell, waist_plane, BAND_FRAC * (waist_plane - hem_plane), WAIST_FLARE_M) base.solidify(shell, base.CLOTH_THICKNESS_M) denim.clamp_waist_residue(shell, waist_plane) # Paint metrics reference the CLEAN rims (band under the flattened waist # edge, hem stitch above the flattened hem). lm.finalize(waist_plane, hem_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, BAND_FRAC, PANEL_HW_FRAC, WAIST_FLARE_M global CORAL_RGB, TEAL_RGB, 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] [--hem-frac F] [--band-frac F] [--panel-frac F] " "[--waist-flare M] [--base-rgb r,g,b] [--panel-rgb r,g,b] " "[--plain]") sys.exit(1) bodies_root = argv[0] out_dir = argv[1] offset = base.PER_BODY_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 "--band-frac" in argv: BAND_FRAC = float(argv[argv.index("--band-frac") + 1]) if "--panel-frac" in argv: PANEL_HW_FRAC = float(argv[argv.index("--panel-frac") + 1]) if "--waist-flare" in argv: WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1]) if "--base-rgb" in argv: CORAL_RGB = tuple( float(v) for v in argv[argv.index("--base-rgb") + 1].split(",")) if "--panel-rgb" in argv: TEAL_RGB = tuple( float(v) for v in argv[argv.index("--panel-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"swim-trunks per-body mode: {len(bodies)} bodies, offset " f"{offset * 1000:.0f} mm, hem-frac {HEM_FRAC}, band-frac {BAND_FRAC}, " f"panel-frac {PANEL_HW_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_trunks_shell(body_dir, out_dir, body, offset) 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()