""" blender_author_tank_top.py (T-1089 wave 2, tank_top — per-body offset shell) Sleeveless tee authored per body via the offset-shell route. Imports blender_author_offset_shell.py as the shared library (scene build, join, offset, solidify, logo UV2, export), blender_author_denim_pants.py for the required boundary-WELD practice, and blender_author_buttondown.py for the per-pixel bake machinery (_gather_tris/_tri_cover/_interp_pos). What the tank adds beyond the tee base, as reusable parameters: * coverage is torso + torso_upper ONLY — no arm segments. The armhole is a real CUT at the shoulder: verts outboard of the strap (|x| > strap_out) and above the underarm plane are deleted, leaving a shoulder strap between the neck scoop and the armhole. * scoop NECKLINE cut — front scoop lower than the back, blended smoothly across the +-y transition, both guaranteed below the jagged natural neck ring (the segment splitter's 4.5-7.6 cm teeth) so no natural boundary survives except the hem. * ring-swallowing thresholds — the natural neck ring and shoulder/arm rings are MEASURED per body (open-boundary probe after weld) and the strap / scoop / underarm cut planes are clamped so every jagged ring vert falls in the deleted zone. Proportional defaults derive from bone landmarks exactly like base.derive_thresholds. * open-rim treatment (denim practice, adapted): the hem ring is FLATTENED to a clean plane (the ring's deepest tooth, so the hem overlaps a pants waistband); the scoop + armhole cut edges are Laplacian-SMOOTHED along the boundary loops into fair curves (a plane can't represent a curved scoop). * region mask is distance-to-opening based: texels within TRIM_W of the neckline edge -> collar band (R), within TRIM_W of an armhole edge -> armhole trim (B), everything else -> body (G). The painted albedo shares the same distance fields (binding bands + stitch lines + hem stitch), so mask and albedo always agree. * logo-capable chest UV2 — base.author_logo_uv with the chest box rescaled (LOGO_SCALE, aspect preserved) and dropped below the front scoop so the decal never crosses the neckline. Region convention (spec): collar band=R (tint_0), body=G (tint_1), armhole trim=B (tint_2). A unused. Bright default tints live in the manifest; the albedo is a bright neutral so tints carry the colour (style pin: modern, texture carries identity, flat toon-friendly). Run (per-body only — offset shells author per body, Q-060): reach blender run blender_author_tank_top \ client/assets/characters/bodies \ client/assets/characters/clothing/tank_top \ [--bodies average_m,child,...] [--offset 0.012] \ [--front-scoop-frac 0.28] [--strap-out-frac 0.80] [--trim-w 0.020] Writes per body: /.glb + /_mask.png /_base_albedo.png Plus: /base_albedo.png (average_m's, shared sidecar) /reference_mask.png (average_m's, runtime fallback) Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060 (per-body authoring for offset shells). """ import os import shutil import sys import bmesh import bpy import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import blender_author_offset_shell as base # noqa: E402 import blender_author_buttondown as bdn # noqa: E402 (per-pixel bake helpers) import blender_author_denim_pants as denim # noqa: E402 (boundary weld) # -------------------------------------------------------------------------- # Garment parameters (average_m metres; scaled per body by bone landmarks) # -------------------------------------------------------------------------- COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper"] OFFSET_M = 0.012 # snug per-body standoff (Q-060 ideal) CLOTH_THICKNESS_M = 0.004 # jersey-weight cloth, same as the tee # Cut proportions. x fractions are of shoulder |x| (upperarm head); scoop # drops are fractions of the spine_01->neck_01 span — the same landmark # language as base.derive_thresholds. STRAP_IN_FRAC = 0.54 # inner strap edge |x| STRAP_OUT_FRAC = 0.80 # outer strap edge |x| (armhole starts here) FRONT_SCOOP_DROP_FRAC = 0.28 # front neckline below the neck head BACK_SCOOP_DROP_FRAC = 0.13 # back neckline below the neck head MIN_STRAP_W_M = 0.024 # never squeeze the strap narrower than this RING_MARGIN_M = 0.008 # clearance under/inside a measured jagged ring SCOOP_BLEND_Y_M = 0.020 # front->back scoop height blend half-width # Open-rim treatment. SMOOTH_ITERS = 12 # Laplacian passes on scoop/armhole boundary loops SMOOTH_LAM = 0.5 HEM_RING_SPAN_FRAC = 0.15 # boundary verts below spine_lo + f*span = hem ring # Painted trim (distances measured to the opening edges, post-offset). TRIM_W_M = 0.020 # collar / armhole binding band width STITCH_W_M = 0.0028 # stitch line half-width HEM_STITCH_UP_M = 0.010 # hem stitch line height above the hem plane LOGO_SCALE = 0.85 # chest box rescale (aspect preserved) LOGO_TOP_GAP_M = 0.020 # logo box top below the front scoop MASK_SIZE = 512 ALBEDO_SIZE = 1024 # Bright neutral jersey — the manifest default tints carry the actual colour. FABRIC_RGB = (0.70, 0.71, 0.73) FABRIC_NOISE = 0.025 TRIM_MUL = 0.90 # binding bands read slightly denser than the body STITCH_MUL = 0.55 # dark stitch lines ALBEDO_SEED = 1094 # deterministic, distinct from other garments def log(msg): print(f"[tank-top] {msg}") # -------------------------------------------------------------------------- # Landmarks + ring probe + cut parameter derivation # -------------------------------------------------------------------------- class TankLandmarks: """Bone landmarks the proportional parameters scale from.""" def __init__(self, armature): bones = armature.data.bones ua_l = bones.get("upperarm_l") ua_r = bones.get("upperarm_r") neck = bones.get("neck_01") spine01 = bones.get("spine_01") if not all([ua_l, ua_r, neck, spine01]): raise RuntimeError("landmark bones missing — not the 65-bone rig?") self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0 self.neck_z = neck.head_local.z self.spine_lo = spine01.head_local.z self.span = self.neck_z - self.spine_lo self.scale = self.shoulder_x / base._REF_SHOULDER_X log(f"landmarks: shoulder_x={self.shoulder_x:.4f} neck_z={self.neck_z:.4f} " f"spine_lo={self.spine_lo:.4f} span={self.span:.4f} scale={self.scale:.3f}") def probe_rings(shell, lm): """Measure the natural open-boundary rings of the welded torso shell. The segment splitter cuts along weight thresholds, so all three natural boundaries (neck ring, arm rings, hem ring) are jagged teeth. The cut thresholds below are clamped so the neck + arm rings fall entirely inside the deleted zone; the hem ring is flattened to a plane instead. """ bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() bm.edges.ensure_lookup_table() bverts = set() for e in bm.edges: if len(e.link_faces) == 1: bverts.update(v.index for v in e.verts) hem_test_z = lm.spine_lo + HEM_RING_SPAN_FRAC * lm.span hem, neck, arm = [], [], [] for i in bverts: co = bm.verts[i].co if co.z < hem_test_z: hem.append(co.copy()) elif abs(co.x) < 0.5 * lm.shoulder_x: neck.append(co.copy()) else: arm.append(co.copy()) bm.free() if not hem or not neck or not arm: raise RuntimeError( f"ring probe incomplete: hem={len(hem)} neck={len(neck)} arm={len(arm)}") rings = { "hem_min_z": min(c.z for c in hem), "hem_max_z": max(c.z for c in hem), "hem_test_z": hem_test_z, "neck_max_ax": max(abs(c.x) for c in neck), "neck_min_z": min(c.z for c in neck), "arm_min_ax": min(abs(c.x) for c in arm), "arm_min_z": min(c.z for c in arm), } log(f"rings: hem n={len(hem)} z {rings['hem_min_z']:.3f}..{rings['hem_max_z']:.3f}; " f"neck n={len(neck)} |x|<={rings['neck_max_ax']:.3f} z>={rings['neck_min_z']:.3f}; " f"arm n={len(arm)} |x|>={rings['arm_min_ax']:.3f} z>={rings['arm_min_z']:.3f}") return rings def derive_cut(lm, rings): """Cut planes from proportional defaults, clamped to swallow the rings.""" strap_out = min(STRAP_OUT_FRAC * lm.shoulder_x, rings["arm_min_ax"] - RING_MARGIN_M) strap_in = max(STRAP_IN_FRAC * lm.shoulder_x, rings["neck_max_ax"] + RING_MARGIN_M) min_w = MIN_STRAP_W_M * lm.scale if strap_out - strap_in < min_w: squeezed = strap_out - min_w floor = rings["neck_max_ax"] + 0.004 if squeezed < floor: log(f"WARNING: strap squeezed against the neck ring " f"(in={squeezed:.3f} floor={floor:.3f}) — using floor") squeezed = floor strap_in = squeezed back_scoop = min(lm.neck_z - BACK_SCOOP_DROP_FRAC * lm.span, rings["neck_min_z"] - RING_MARGIN_M) front_scoop = min(lm.neck_z - FRONT_SCOOP_DROP_FRAC * lm.span, back_scoop) armhole_z = rings["arm_min_z"] - RING_MARGIN_M params = { "strap_in": strap_in, "strap_out": strap_out, "strap_mid": 0.5 * (strap_in + strap_out), "front_scoop": front_scoop, "back_scoop": back_scoop, "armhole_z": armhole_z, "hem_plane": rings["hem_min_z"], "hem_test_z": rings["hem_test_z"], } log(f"cut: strap |x| {strap_in:.3f}..{strap_out:.3f}, scoop front {front_scoop:.3f} " f"back {back_scoop:.3f}, armhole z>{armhole_z:.3f}, hem plane {params['hem_plane']:.3f}") return params # -------------------------------------------------------------------------- # Geometry: tank cut + hem flatten + boundary smoothing # -------------------------------------------------------------------------- def _scoop_z(y, params): """Neckline height at this y — front scoop blended into the back scoop.""" t = (y * base.FRONT_Y_SIGN) / SCOOP_BLEND_Y_M * 0.5 + 0.5 t = min(max(t, 0.0), 1.0) return params["back_scoop"] + (params["front_scoop"] - params["back_scoop"]) * t def tank_cut(shell, params): """Delete the neck scoop and the armholes. Neck zone: |x| < strap_in AND z above the (front/back blended) scoop. Armhole zone: |x| > strap_out AND z above the underarm plane. Both thresholds were clamped so the jagged natural neck/arm rings fall entirely inside the deleted zone — the only natural boundary that survives is the hem ring. """ bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() doomed = [] for v in bm.verts: x, y, z = v.co.x, v.co.y, v.co.z ax = abs(x) if ax < params["strap_in"] and z > _scoop_z(y, params): doomed.append(v) elif ax > params["strap_out"] and z > params["armhole_z"]: doomed.append(v) bmesh.ops.delete(bm, geom=doomed, context='VERTS') bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"tank cut removed {len(doomed)} verts; {len(shell.data.vertices)} remain") def flatten_hem(shell, params): """Pull the hem ring's jagged teeth onto one clean plane (denim practice). The plane sits at the ring's DEEPEST tooth, so the straightened hem keeps overlapping a pants waistband instead of retreating to the shallowest notch (a tank tucks over the waist; extra length is correct here). """ me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.verts.ensure_lookup_table() bm.edges.ensure_lookup_table() hem_idx = set() for e in bm.edges: if len(e.link_faces) == 1: for v in e.verts: if v.co.z < params["hem_test_z"]: hem_idx.add(v.index) for i in hem_idx: bm.verts[i].co.z = params["hem_plane"] bm.to_mesh(me) bm.free() me.update() log(f"flattened hem ring: {len(hem_idx)} verts -> z={params['hem_plane']:.3f}") def smooth_open_rims(shell, params, iters=None, lam=SMOOTH_LAM): """Laplacian-relax the scoop + armhole boundary loops into fair curves. Vertex-deletion cuts leave sawtooth edges at mesh resolution; a plane flatten (denim) can't express a curved scoop, so each boundary vert is repeatedly pulled toward the midpoint of its two loop neighbours. Hem verts (already on their plane) are pinned; interior verts never move, so weights/UVs are untouched. """ iters = SMOOTH_ITERS if iters is None else iters me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.verts.ensure_lookup_table() bm.edges.ensure_lookup_table() nbr = {} for e in bm.edges: if len(e.link_faces) == 1: a, b = e.verts nbr.setdefault(a.index, []).append(b.index) nbr.setdefault(b.index, []).append(a.index) hem_lim = params["hem_plane"] + 0.001 movable = [i for i, ns in nbr.items() if len(ns) == 2 and bm.verts[i].co.z > hem_lim] for _ in range(iters): moved = {} for i in movable: n1, n2 = nbr[i] mid = (bm.verts[n1].co + bm.verts[n2].co) * 0.5 moved[i] = bm.verts[i].co.lerp(mid, lam) for i, co in moved.items(): bm.verts[i].co = co bm.to_mesh(me) bm.free() me.update() log(f"smoothed {len(movable)} scoop/armhole rim verts ({iters} passes)") def collect_trim_edges(shell, params): """Opening-edge vert positions (post-offset) for the distance-based trim. Returns (neck_pts, arm_pts) float32 arrays. Hem verts are excluded — the hem gets a painted stitch line, not a tinted band (spec: 3 regions). """ bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() bm.edges.ensure_lookup_table() neck, arm = [], [] seen = set() for e in bm.edges: if len(e.link_faces) != 1: continue for v in e.verts: if v.index in seen: continue seen.add(v.index) if v.co.z < params["hem_test_z"]: continue if abs(v.co.x) < params["strap_mid"]: neck.append(v.co[:]) else: arm.append(v.co[:]) bm.free() log(f"trim edges: neckline {len(neck)} verts, armholes {len(arm)} verts") return (np.array(neck, dtype=np.float32), np.array(arm, dtype=np.float32)) # -------------------------------------------------------------------------- # Per-pixel bake: region mask + painted albedo share the distance fields # -------------------------------------------------------------------------- def _min_dist(pos, pts): """Min euclidean distance from each row of pos (N,3) to the set pts (K,3).""" if pts.size == 0: return np.full(pos.shape[0], np.inf, dtype=np.float32) d2 = ((pos[:, None, :] - pts[None, :, :]) ** 2).sum(axis=2) return np.sqrt(d2.min(axis=1)) def _classify_px(dn, da, trim_w): """One-hot RGBA rows: collar band R / armhole trim B / body G.""" n = dn.shape[0] rgba = np.zeros((n, 4), dtype=np.float32) is_r = (dn < trim_w) & (dn <= da) is_b = (da < trim_w) & (da < dn) rgba[:, 1] = 1.0 rgba[is_r] = (1.0, 0.0, 0.0, 0.0) rgba[is_b] = (0.0, 0.0, 1.0, 0.0) return rgba def _paint_px(pos, rows, dn, da, dims): """Painted albedo: binding bands, band stitch lines, hem stitch (in-place).""" mul = np.ones(pos.shape[0], dtype=np.float32) d = np.minimum(dn, da) mul[d < dims["trim_w"]] = TRIM_MUL mul[np.abs(d - dims["trim_w"]) < dims["stitch_w"]] = STITCH_MUL hem_line = np.abs(pos[:, 2] - dims["hem_stitch_z"]) < dims["stitch_w"] mul[hem_line] = STITCH_MUL out = rows * mul[:, None] np.clip(out, 0.0, 1.0, out) return out def bake_tank_maps(shell, lm, params, neck_pts, arm_pts, mask_path, albedo_path): """One pass over UV0: bake _mask.png + _base_albedo.png.""" dims = { "trim_w": TRIM_W_M * lm.scale, "stitch_w": STITCH_W_M * lm.scale, "hem_stitch_z": params["hem_plane"] + HEM_STITCH_UP_M * lm.scale, } tris = bdn._gather_tris(shell) mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32) mbuf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe) rng = np.random.default_rng(ALBEDO_SEED) fabric = np.array(FABRIC_RGB, dtype=np.float32) noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0) for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris: cover = bdn._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE) if cover is not None: pos = bdn._interp_pos(cover, co_a, co_b, co_c) dn = _min_dist(pos, neck_pts) da = _min_dist(pos, arm_pts) mbuf[cover[0], cover[1], :] = _classify_px(dn, da, dims["trim_w"]) cover = bdn._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE) if cover is not None: pos = bdn._interp_pos(cover, co_a, co_b, co_c) dn = _min_dist(pos, neck_pts) da = _min_dist(pos, arm_pts) abuf[cover[0], cover[1], :] = _paint_px( pos, abuf[cover[0], cover[1], :], dn, da, dims) tot = MASK_SIZE * MASK_SIZE log("mask texels: collar={:.1f}% body={:.1f}% armhole={:.1f}%".format( 100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot, 100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot, 100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot)) def _save(buf, name, path, alpha): arr = buf if not alpha: arr = np.concatenate( [buf, np.ones(buf.shape[:2] + (1,), dtype=np.float32)], axis=2) img = bpy.data.images.new(name, buf.shape[1], buf.shape[0], alpha=alpha) img.pixels.foreach_set(arr.reshape(-1)) img.update() img.filepath_raw = path img.file_format = 'PNG' img.save() return img _save(mbuf, "tank_region_mask", mask_path, alpha=True) log(f"baked region mask -> {mask_path}") albedo_img = _save(abuf, "tank_base_albedo", albedo_path, alpha=False) log(f"saved painted albedo -> {albedo_path}") return albedo_img # -------------------------------------------------------------------------- # Author one body # -------------------------------------------------------------------------- def author_tank(body_dir, out_dir, body, offset): base.clear_scene() base.COVERED_SEGMENTS = COVERED_SEGMENTS shell, armature = base.build_covered_mesh(body_dir) lm = TankLandmarks(armature) denim.weld_boundaries(shell) # required practice: weld seam rings rings = probe_rings(shell, lm) params = derive_cut(lm, rings) tank_cut(shell, params) flatten_hem(shell, params) smooth_open_rims(shell, params) base.offset_outward(shell, offset) neck_pts, arm_pts = collect_trim_edges(shell, params) base.solidify(shell, CLOTH_THICKNESS_M) # Logo UV2: chest box rescaled (aspect preserved) and dropped below the # front scoop so the decal sits fully on cloth. thr = base.derive_thresholds(armature) half_w = (thr["chest_x"][1] - thr["chest_x"][0]) * LOGO_SCALE / 2.0 box_h = (thr["chest_z"][1] - thr["chest_z"][0]) * LOGO_SCALE top = params["front_scoop"] - LOGO_TOP_GAP_M * lm.scale thr_logo = dict(thr) thr_logo["chest_x"] = (-half_w, half_w) thr_logo["chest_z"] = (top - box_h, top) base.author_logo_uv(shell, thr_logo) mask_path = os.path.join(out_dir, f"{body}_mask.png") albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png") albedo_img = bake_tank_maps(shell, lm, params, neck_pts, arm_pts, mask_path, albedo_path) base.assign_fabric_material(shell, albedo_img) base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb")) # -------------------------------------------------------------------------- # Entry # -------------------------------------------------------------------------- def main(): global FRONT_SCOOP_DROP_FRAC, STRAP_OUT_FRAC, TRIM_W_M, FABRIC_RGB argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if len(argv) < 2: print("Usage: -- [--bodies a,b,c] [--offset M] " "[--front-scoop-frac F] [--strap-out-frac F] [--trim-w M] " "[--base-rgb r,g,b]") 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 "--front-scoop-frac" in argv: FRONT_SCOOP_DROP_FRAC = float(argv[argv.index("--front-scoop-frac") + 1]) if "--strap-out-frac" in argv: STRAP_OUT_FRAC = float(argv[argv.index("--strap-out-frac") + 1]) if "--trim-w" in argv: TRIM_W_M = float(argv[argv.index("--trim-w") + 1]) if "--base-rgb" in argv: FABRIC_RGB = tuple( float(v) for v in argv[argv.index("--base-rgb") + 1].split(",")) 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"per-body mode: {len(bodies)} bodies, offset {offset * 1000:.1f} mm, " f"front scoop {FRONT_SCOOP_DROP_FRAC}, strap out {STRAP_OUT_FRAC}") 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_tank(body_dir, out_dir, body, offset) results.append((body, "ok")) except Exception as exc: # noqa: BLE001 — per-body isolation 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()