""" blender_author_swimsuit.py (T-1089 wave 2, swimsuit_onepiece — swim family) Authors a one-piece SWIMSUIT as per-body offset shells, reusing blender_author_offset_shell.py as a library (scene build, join, offset, solidify, GLB export) and blender_author_offset_coverall.py's per-texel rasterizer (_tri_texels) for crisp mask/albedo boundaries. Coverage: torso + torso_upper + hips — NO arms, NO legs. The torso_upper is cut down to narrow SHOULDER STRAPS (front scoop + back scoop + open armholes); the bottom gets HIGH-CUT leg openings that rise from the crotch gusset to the hip line at the sides. What this companion adds, as reusable parameters: * seg_leg_upper_l/r are INCLUDED in the covered set purely as cut stock: the seg_hips lower boundary is splitter teeth (probe: 9-13 cm jag, z 0.884..1.015 on average_m — worse than the waist teeth denim flattens). Welding the legs in makes that seam interior, so the high-cut leg cut slices through CLEAN thigh geometry and no natural teeth survive; the tubes below the cut are deleted whole. * measurement-guarded straps: the strap corridor is proportional (STRAP_X0/X1_FRAC of shoulder |x|) but clamped per body against the MEASURED neck-seam ring (max |x| + guard) and armscye ring (min |x| − guard), because the armscye reaches |x| = 0.79..0.92 x shoulder depending on body fork (probe) and a fixed fraction would slice into the seam. * measurement-derived armhole plane: z_arm = (armscye ring min z) − drop, so the whole jagged arm-seam ring is guaranteed deleted on every body. * analytic high-cut leg surface z_leg(x, y): crotch gusset (below-crotch at |x| < gusset half-width, so the gusset never opens), rising outward to the thigh-head line, with a front/back blend that keeps the seat covered (RISE_BACK < RISE_FRONT). The SAME function drives the cut, the rim flattening and the trim band in the mask, so they always agree. * open-rim FLATTENING onto the analytic lines (denim practice, post-offset): scoop rims -> scoop plane, armhole rims -> z_arm, leg rims -> z_leg, strap side edges -> |x| snapped to the exact strap planes. Regions (RGBA mask, toon_garment.gdshader; spec: straps+trim=R, body=G, side color-block=B): R = shoulder straps + edge trim along every opening (scoop/armhole/leg) G = main body fabric B = side color-block panels (normal-gated: |n.x| >= BLOCK_NX_MIN, below the armholes) — the saturated-color-block default lives in the tints. Painted albedo: flat per-region luminance + woven noise + dark stitch lines on region boundaries (toon-friendly; identity carried by the texture). A parked logo_uv TEXCOORD_1 layer ships for channel consistency (not logo-capable). Per-body mode only (offset shells author per body, Q-060): tooling/blender --background --python \ tooling/scripts/blender/blender_author_swimsuit.py -- \ client/assets/characters/bodies \ client/assets/characters/clothing/swimsuit_onepiece \ [--bodies average_m,child,...] [--offset 0.012] 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 os import shutil import sys import bmesh import bpy import numpy as np # Make the sibling modules importable when Blender runs this file directly. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import blender_author_offset_shell as base # noqa: E402 import blender_author_offset_coverall as cov # noqa: E402 (per-texel rasterizer) log = base.log FRONT = base.FRONT_Y_SIGN # bodies face -Y (verified in base) # -------------------------------------------------------------------------- # Parameters # -------------------------------------------------------------------------- COVERED_SEGMENTS = [ "seg_torso", "seg_torso_upper", "seg_hips", # cut stock only — welded in so the hips|leg seam teeth become interior, # then everything below the high-cut line is deleted. "seg_leg_upper_l", "seg_leg_upper_r", ] WELD_DIST = 5e-4 # boundary weld tolerance (0.5 mm, denim practice) MIN_LOOP_VERTS = 6 # ignore sliver boundary loops when measuring rings # Straps — proportional corridor over the shoulder top, clamped per body # against the measured neck ring (outside it) and armscye ring (inside it). STRAP_X0_FRAC = 0.52 # inner strap edge, fraction of shoulder |x| STRAP_X1_FRAC = 0.74 # outer strap edge STRAP_MIN_W_FRAC = 0.10 # minimum acceptable strap width (of shoulder |x|) NECK_GUARD_M = 0.004 # keep this far outside the neck-seam ring ARM_GUARD_M = 0.004 # keep this far inside the armscye ring # Necklines — fractions of the spine_01->neck_01 span above spine_01 head. FRONT_SCOOP_FRAC = 0.66 # front neckline (covers the seg_torso top band) BACK_SCOOP_FRAC = 0.54 # back scoop, slightly deeper ARMHOLE_DROP_M_REF = 0.015 # armhole plane below the measured armscye min z # High-cut leg openings — all spans derive from (thigh head z − crotch z). GUSSET_HALF_FRAC = 0.50 # crotch gusset half-width, fraction of thigh |x| LEG_OUT_X_FRAC = 1.55 # |x| where the rise reaches its max (of thigh |x|) LEG_RISE_FRONT_FRAC = 1.00 # front/side rise: up to the thigh-head line LEG_RISE_BACK_FRAC = 0.58 # back rise: lower, keeps the seat covered LEG_RISE_POW = 1.35 # >1 = convex sweep (classic high-cut) LEG_YBLEND_FRAC = 0.35 # front/back blend half-width (of thigh |x|) GUSSET_DROP_M_REF = 0.004 # gusset cut sits below the crotch -> never opens # Region mask / painted albedo. TRIM_W_M_REF = 0.016 # edge-trim band width along the openings BLOCK_NX_MIN = 0.74 # side panel: |normal.x| threshold (normal-gated) ALBEDO_LUMA = {"body": 0.62, "trim": 0.56, "block": 0.67} STITCH_LUMA = 0.30 ALBEDO_NOISE = 0.02 NOISE_SEED = 3089 LABELS = ["body", "trim", "block"] LABEL_ID = {name: i for i, name in enumerate(LABELS)} LABEL_RGBA_ARR = np.array( [ (0.0, 1.0, 0.0, 0.0), # body -> G (1.0, 0.0, 0.0, 0.0), # trim -> R (straps + edge trim) (0.0, 0.0, 1.0, 0.0), # block -> B (side color-block) ], dtype=np.float32, ) LABEL_LUMA_ARR = np.array([ALBEDO_LUMA[n] for n in LABELS], dtype=np.float32) _REF_SHOULDER_X = 0.1919 # average_m upperarm head |x| (same anchor as base) # -------------------------------------------------------------------------- # Per-body landmarks + measured rings # -------------------------------------------------------------------------- class SuitLandmarks: """Cut/mask parameters from one body's bones, crotch probe and rings.""" def __init__(self, armature, crotch_z): bones = armature.data.bones def bone(name): b = bones.get(name) if b is None: raise RuntimeError(f"landmark bone {name} missing") return b ua_l, ua_r = bone("upperarm_l"), bone("upperarm_r") self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0 self.scale = self.shoulder_x / _REF_SHOULDER_X neck = bone("neck_01") spine01 = bone("spine_01") span = neck.head_local.z - spine01.head_local.z self.scoop_front = spine01.head_local.z + FRONT_SCOOP_FRAC * span self.scoop_back = spine01.head_local.z + BACK_SCOOP_FRAC * span thigh = bone("thigh_l") self.thigh_x = abs(thigh.head_local.x) self.thigh_z = thigh.head_local.z self.crotch_z = crotch_z self.rise_span = self.thigh_z - self.crotch_z self.gx = GUSSET_HALF_FRAC * self.thigh_x self.out_x = LEG_OUT_X_FRAC * self.thigh_x self.yb = LEG_YBLEND_FRAC * self.thigh_x self.gusset_drop = GUSSET_DROP_M_REF * self.scale self.trim_w = TRIM_W_M_REF * self.scale # Filled by apply_ring_measurements(): self.sx0 = STRAP_X0_FRAC * self.shoulder_x self.sx1 = STRAP_X1_FRAC * self.shoulder_x self.z_arm = self.scoop_front # placeholder until rings are measured def apply_ring_measurements(self, neck_max_ax, arm_min_ax, arm_min_z): self.sx0 = max(STRAP_X0_FRAC * self.shoulder_x, neck_max_ax + NECK_GUARD_M * self.scale) self.sx1 = min(STRAP_X1_FRAC * self.shoulder_x, arm_min_ax - ARM_GUARD_M * self.scale) min_w = STRAP_MIN_W_FRAC * self.shoulder_x if self.sx1 - self.sx0 < min_w: log(f"WARNING: strap corridor pinched " f"({(self.sx1 - self.sx0) * 1000:.1f} mm) — widening inward") self.sx0 = max(neck_max_ax + NECK_GUARD_M * self.scale, self.sx1 - min_w) self.z_arm = arm_min_z - ARMHOLE_DROP_M_REF * self.scale log(f"landmarks: straps |x|=[{self.sx0:.3f},{self.sx1:.3f}] " f"scoop_f={self.scoop_front:.3f} scoop_b={self.scoop_back:.3f} " f"z_arm={self.z_arm:.3f} crotch={self.crotch_z:.3f} " f"thigh_z={self.thigh_z:.3f} gusset<|x|<{self.gx:.3f} " f"trim={self.trim_w * 1000:.1f}mm") # ---- analytic surfaces (numpy-vectorised; scalars work too) ---------- def z_leg(self, x, y): """High-cut leg-opening surface: gusset floor below the crotch, rising outward to the thigh-head line; back rises less (seat).""" t = np.clip((np.abs(x) - self.gx) / max(self.out_x - self.gx, 1e-6), 0.0, 1.0) ** LEG_RISE_POW f = np.clip((np.asarray(y) * FRONT + self.yb) / (2.0 * self.yb), 0.0, 1.0) rise = self.rise_span * ( LEG_RISE_BACK_FRAC + (LEG_RISE_FRONT_FRAC - LEG_RISE_BACK_FRAC) * f) return (self.crotch_z - self.gusset_drop) + t * rise def scoop(self, y): """Neckline level: front scoop on the front side, back scoop behind.""" return np.where(np.asarray(y) * FRONT > 0.0, self.scoop_front, self.scoop_back) def probe_crotch(body_dir): """Import seg_hips alone to measure the crotch (its lowest point) exactly (denim practice — the joined mesh's min z is the knee cut stock).""" base.clear_scene() objs = base.import_glb(os.path.join(body_dir, "seg_hips.glb")) zs = [] for o in objs: if base.is_body_mesh(o): zs.extend(v.co.z for v in o.data.vertices) if not zs: raise RuntimeError("seg_hips.glb yielded no skinned mesh") return min(zs) # -------------------------------------------------------------------------- # Geometry: weld, ring measurement, cuts, rim flattening # -------------------------------------------------------------------------- def weld_boundaries(shell): """Merge coincident segment-boundary verts so the offset can't open cracks (weights/UVs identical on coincident verts, so skinning is unaffected).""" me = shell.data bm = bmesh.new() bm.from_mesh(me) before = len(bm.verts) bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST) bm.to_mesh(me) bm.free() me.update() log(f"welded segment boundaries: {before} -> {len(me.vertices)} verts") def _boundary_loops(bm): """Connected open-boundary loops as lists of vert indices.""" adj = {} for e in bm.edges: if len(e.link_faces) != 1: continue a, b = e.verts[0].index, e.verts[1].index adj.setdefault(a, set()).add(b) adj.setdefault(b, set()).add(a) seen = set() loops = [] for start in adj: if start in seen: continue stack, comp = [start], [] while stack: v = stack.pop() if v in seen: continue seen.add(v) comp.append(v) stack.extend(adj[v] - seen) loops.append(comp) return loops def measure_rings(shell, lm): """Locate the neck-seam and armscye boundary rings on the welded shell. Expected loops: neck ring, 2 armscye rings, 2 knee rings (cut stock; median z below the crotch — ignored). Sliver loops (< MIN_LOOP_VERTS) are skipped. Returns (neck_max_ax, arm_min_ax, arm_min_z). """ bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() candidates = [] for comp in _boundary_loops(bm): if len(comp) < MIN_LOOP_VERTS: continue zs = sorted(bm.verts[i].co.z for i in comp) med_z = zs[len(zs) // 2] if med_z < lm.crotch_z: continue # knee ring on the leg cut stock axs = [abs(bm.verts[i].co.x) for i in comp] candidates.append({ "med_ax": sorted(axs)[len(axs) // 2], "max_ax": max(axs), "min_ax": min(axs), "min_z": min(bm.verts[i].co.z for i in comp), "n": len(comp), }) bm.free() if len(candidates) < 3: raise RuntimeError( f"expected neck + 2 armscye rings, found {len(candidates)}") candidates.sort(key=lambda c: c["med_ax"]) neck = candidates[0] arms = candidates[-2:] arm_min_ax = min(a["min_ax"] for a in arms) arm_min_z = min(a["min_z"] for a in arms) log(f"rings: neck max|x|={neck['max_ax']:.3f} ({neck['n']}v) " f"armscye min|x|={arm_min_ax:.3f} min z={arm_min_z:.3f}") return neck["max_ax"], arm_min_ax, arm_min_z def suit_cuts(shell, lm): """Delete everything the swimsuit doesn't cover: - neckline scoops between the straps (|x| < sx0, z above scoop level) - armholes outside the straps (|x| > sx1, z above the armhole plane) - legs below the analytic high-cut surface z_leg(x, y) The strap corridor (sx0 <= |x| <= sx1) survives over the shoulder.""" 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 z < float(lm.z_leg(x, y)): doomed.append(v) elif ax < lm.sx0 and z > float(lm.scoop(y)): doomed.append(v) elif ax > lm.sx1 and z > lm.z_arm: doomed.append(v) bmesh.ops.delete(bm, geom=doomed, context='VERTS') bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"suit cuts removed {len(doomed)} verts; " f"{len(shell.data.vertices)} remain") def flatten_rims(shell, lm): """Pull every open rim onto its analytic line (post-offset, denim practice): leg rims -> z_leg surface, scoop rims -> scoop plane, armhole rims -> z_arm plane, strap side edges -> |x| snapped to the strap planes. Only boundary verts move; weights/UVs ride along. The leg/top split plane sits MID-GAP between the thigh line (the leg rims' analytic maximum) and the lowest top opening (armhole plane or back scoop) — a dead zone with no legitimate boundary verts. A tight margin (thigh_z + 2 cm) is NOT enough: the 12 mm outward offset runs before flattening and drifts the high-cut side-apex verts upward, and on thin_f two thigh-weighted apex verts crossed it, were classified as armhole rim and teleported ~36 cm up to z_arm — QA showed them as sliver spikes off the seat in deep Crouch_Fwd (worst vert-from-centroid 294 mm).""" me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.verts.ensure_lookup_table() snap_eps = 0.006 * lm.scale leg_z_max = 0.5 * (lm.thigh_z + min(lm.z_arm, lm.scoop_back)) counts = {"leg": 0, "scoop": 0, "armhole": 0, "strap": 0} boundary = set() for e in bm.edges: if len(e.link_faces) == 1: boundary.update(v.index for v in e.verts) for i in boundary: v = bm.verts[i] x, y, z = v.co.x, v.co.y, v.co.z ax = abs(x) if z < leg_z_max: v.co.z = float(lm.z_leg(x, y)) counts["leg"] += 1 elif ax < lm.sx0 - snap_eps: v.co.z = float(lm.scoop(y)) counts["scoop"] += 1 elif ax > lm.sx1 + snap_eps: v.co.z = lm.z_arm counts["armhole"] += 1 else: edge = lm.sx0 if abs(ax - lm.sx0) <= abs(ax - lm.sx1) else lm.sx1 v.co.x = edge if x >= 0.0 else -edge counts["strap"] += 1 bm.to_mesh(me) bm.free() me.update() log("flattened rims: " + " ".join(f"{k}={v}" for k, v in counts.items())) # -------------------------------------------------------------------------- # Region classification (per texel — interpolated position + normal) # -------------------------------------------------------------------------- def classify_texels(pos, nrm, lm): """Classify N texels. pos/nrm are (N,3) body-local arrays. Returns (N,) uint8 label ids. Precedence: trim (straps + opening edges), block, body.""" x, y, z = pos[:, 0], pos[:, 1], pos[:, 2] ax = np.abs(x) tw = lm.trim_w scoop = lm.scoop(y) zl = lm.z_leg(x, y) strap = (ax >= lm.sx0 - 0.5 * tw) & (ax <= lm.sx1 + 0.5 * tw) \ & (z >= scoop - tw) scoop_trim = (ax < lm.sx0) & (z >= scoop - tw) arm_trim = (ax > lm.sx1) & (z >= lm.z_arm - tw) leg_trim = z <= zl + tw trim = strap | scoop_trim | arm_trim | leg_trim block = (np.abs(nrm[:, 0]) >= BLOCK_NX_MIN) & (z <= lm.z_arm - tw) lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8) lab[block] = LABEL_ID["block"] lab[trim] = LABEL_ID["trim"] return lab def bake_mask_and_albedo(shell, lm, mask_path, albedo_name, seed): """One pass over the faces (pre-solidify — exactly one face per texel): bake the RGBA region mask AND the painted albedo (flat per-region luminance + stitch lines on region boundaries + woven noise).""" W = H = base.MASK_SIZE mask = np.zeros((H, W, 4), dtype=np.float32) mask[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe) albedo = np.zeros((H, W, 4), dtype=np.float32) albedo[:, :, 0:3] = ALBEDO_LUMA["body"] albedo[:, :, 3] = 1.0 label_map = np.full((H, W), LABEL_ID["body"], dtype=np.uint8) covered = np.zeros((H, W), dtype=bool) me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() bm.normal_update() uv_layer = bm.loops.layers.uv.active if uv_layer is None: raise RuntimeError("no active UV layer for bake") for face in bm.faces: loops = face.loops[:] uvs = [loop[uv_layer].uv for loop in loops] pos = [loop.vert.co for loop in loops] nrm = [loop.vert.normal for loop in loops] for i in range(1, len(loops) - 1): tri = (0, i, i + 1) ys, xs, w0, w1, w2 = cov._tri_texels( uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H) if ys.size == 0: continue p = np.empty((ys.size, 3), dtype=np.float32) n = np.empty((ys.size, 3), dtype=np.float32) for axis in range(3): p[:, axis] = (w0 * pos[tri[0]][axis] + w1 * pos[tri[1]][axis] + w2 * pos[tri[2]][axis]) n[:, axis] = (w0 * nrm[tri[0]][axis] + w1 * nrm[tri[1]][axis] + w2 * nrm[tri[2]][axis]) n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9) lab = classify_texels(p, n, lm) label_map[ys, xs] = lab covered[ys, xs] = True mask[ys, xs] = LABEL_RGBA_ARR[lab] albedo[ys, xs, 0:3] = LABEL_LUMA_ARR[lab][:, None] bm.free() total = max(int(covered.sum()), 1) tex_counts = np.bincount(label_map[covered], minlength=len(LABELS)) log("region texels: " + " ".join( f"{LABELS[i]}={int(c)} ({100.0 * c / total:.1f}%)" for i, c in enumerate(tex_counts) if c > 0)) # Woven-feel noise over the fills, before stitch lines (lines stay crisp). rng = np.random.default_rng(seed) noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE albedo[:, :, 0:3] = np.clip(albedo[:, :, 0:3] + noise, 0.0, 1.0) # Stitch lines: label transitions where BOTH texels belong to rasterised # geometry (skipping UV-island borders against background). edge = np.zeros((H, W), dtype=bool) dh = (label_map[:, 1:] != label_map[:, :-1]) & covered[:, 1:] & covered[:, :-1] edge[:, 1:] |= dh edge[:, :-1] |= dh dv = (label_map[1:, :] != label_map[:-1, :]) & covered[1:, :] & covered[:-1, :] edge[1:, :] |= dv edge[:-1, :] |= dv albedo[edge, 0:3] = STITCH_LUMA log(f"stitch lines on {int(edge.sum())} boundary texels") # Alpha floor 2/255: keeps Godot's fix_alpha_border import pass a no-op # (channel-packed region data, not transparency — coverall lesson). mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0) img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True) img_mask.alpha_mode = 'CHANNEL_PACKED' img_mask.pixels.foreach_set(mask.reshape(-1)) img_mask.update() img_mask.filepath_raw = mask_path img_mask.file_format = 'PNG' img_mask.save() log(f"baked region mask -> {mask_path}") img_albedo = bpy.data.images.new(f"albedo_{albedo_name}", W, H, alpha=False) img_albedo.pixels.foreach_set(albedo.reshape(-1)) img_albedo.update() return img_albedo # -------------------------------------------------------------------------- # Parked logo UV2 (not logo-capable; the shader still samples UV2) # -------------------------------------------------------------------------- def author_parked_uv2(shell): 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 authored fully parked (not logo-capable)") # -------------------------------------------------------------------------- # Per-body authoring # -------------------------------------------------------------------------- def purge_strays(shell, armature): """Drop authoring-scene leftovers before export (defensive hygiene). Investigated for T-1089 wave 2: the 'Icosphere' seen when re-importing any garment GLB is NOT file content — the raw glTF JSON contains exactly one mesh (the shell). It is a bone-display widget the Blender IMPORTER fabricates (bone_heuristic), which also leaks one such object into the authoring scene per body-segment import session (base.clear_scene()'s bpy.ops select_all can miss it). Exports were never polluted (use_selection holds), but removing every object that is not the shell or its armature keeps each per-body authoring cycle hermetic. Bone custom_shape references are cleared for the same reason (display-only, no glTF effect).""" cleared = 0 for pb in armature.pose.bones: if pb.custom_shape is not None: pb.custom_shape = None cleared += 1 strays = [o for o in bpy.data.objects if o not in (shell, armature)] for o in strays: bpy.data.objects.remove(o, do_unlink=True) if cleared or strays: log(f"purged {len(strays)} stray objects, cleared {cleared} bone " f"custom shapes before export") def author_swimsuit(body_dir, out_dir, body, offset, seed): crotch_z = probe_crotch(body_dir) base.clear_scene() base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this shell, armature = base.build_covered_mesh(body_dir) weld_boundaries(shell) lm = SuitLandmarks(armature, crotch_z) lm.apply_ring_measurements(*measure_rings(shell, lm)) suit_cuts(shell, lm) base.offset_outward(shell, offset) flatten_rims(shell, lm) # Bake + UV2 BEFORE solidify: the inner shell duplicates every face with # the same atlas UVs but flipped normals — pre-solidify there is exactly # one face per texel (coverall lesson; the block region is normal-gated). author_parked_uv2(shell) albedo_img = bake_mask_and_albedo( shell, lm, os.path.join(out_dir, f"{body}_mask.png"), body, seed) base.solidify(shell, base.CLOTH_THICKNESS_M) base.assign_fabric_material(shell, albedo_img) # Shared-name albedo before export so the GLB-embedded texture extracts # to the _base_albedo.png convention (coverall lesson). base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png")) purge_strays(shell, armature) base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb")) shutil.copy2(os.path.join(out_dir, "base_albedo.png"), os.path.join(out_dir, f"{body}_base_albedo.png")) def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if len(argv) < 2: print("Usage: -- [--bodies a,b,c] [--offset M]") 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]) 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"swimsuit per-body mode: {len(bodies)} bodies, " f"offset {offset * 1000:.0f} mm") results = [] for i, body in enumerate(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_swimsuit(body_dir, out_dir, body, offset, seed=NOISE_SEED + i) results.append((body, "ok")) except Exception as exc: log(f"ERROR {body}: {exc}") import traceback traceback.print_exc() results.append((body, f"error: {exc}")) # Runtime fallback + shared sidecars mirror the reference body. 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()