""" blender_author_parka.py (T-1089 wave 2 — parka_thrds, hip-length insulated parka) Cold-weather parka authored as per-body offset shells — the FIRST CANON-BRANDED garment (thrds, the Braemar fiber cooperative, wiki/star-systems/GJ-475). Companion to blender_author_offset_shell.py (imported as a library): reuses the base module's segment join / bone-ratio thresholds / offset / solidify / logo-UV2 / export machinery and layers the parka geometry + texture identity on top, combining the proven practices of the wave-1 family: * HIP-LENGTH — covers torso + torso_upper + FULL arms + the TOP of the hips zone: seg_hips joins the shell, the coincident torso|hips overlap band is WELDED before offsetting (denim practice — un-welded rings offset along diverging normals and open cracks), and the hem is cut at a bone-derived plane between the pelvis head and spine_01 head (no skirt panel below). The vert-threshold hem cut leaves jagged teeth, so the open hem rim is FLATTENED onto its own valley plane (denim flatten practice) for a clean straight hem, which Solidify(use_rim) then caps. * HOOD-DOWN ROLL — the hoodie's rolled-collar inflation with bulkier parka parameters (radial bulge away from the neck axis, back-biased, rim lift). * CHUNKY INSULATED READ — painted, not modelled: horizontal quilt channel lines (constant-z on the body, constant-|x| around the sleeves) with a soft per-channel "puff" luminance gradient; big front patch pockets with flaps; a storm-flap front placket with bright zip-teeth dashes; hem drawcord band; sleeve cuff bands. All identity is carried as LUMINANCE so the luma-preserving toon_garment recolor keeps it under any tint. * REGIONS (spec): R = hood roll + collar, G = body, B = sleeves, A = pocket / quilt trim (quilt lines, pockets, storm flap, cuff bands). Albedo and mask come from ONE shared per-texel feature-field evaluation (denim practice) so they always agree. The mask ships CHANNEL_PACKED with an alpha floor (coverall lesson: Godot's fix_alpha_border pass + Blender's straight-alpha PNG save both destroy a plain A channel). * LOGO-CAPABLE — left-breast chest patch (the storm flap owns the centre line), same bone-ratio box as jacket_modern; painted as a brighter patch rectangle kept in the G region so the decal reads on the body tint. Per-body only (Q-060: offset shells author per body, never SD-fit): tooling/blender --background --python \ tooling/garment-fit/blender_author_parka.py -- \ client/assets/characters/bodies client/assets/characters/clothing/parka_thrds \ [--bodies a,b,c] [--offset 0.026] [--hem-frac 0.15] Writes per body: /.glb (skinned, painted albedo embedded) /_mask.png (RGBA region mask, that body's UV0) /_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. """ import importlib.util import os import shutil import sys import bmesh import bpy import numpy as np from mathutils import Vector # -------------------------------------------------------------------------- # Load the base offset-shell module (shared engine machinery). # -------------------------------------------------------------------------- _HERE = os.path.dirname(os.path.abspath(__file__)) _spec = importlib.util.spec_from_file_location( "offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py")) base = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(base) def log(msg): print(f"[parka] {msg}") # -------------------------------------------------------------------------- # PARAMS — parka_thrds. A parka-family variant should only touch this block. # -------------------------------------------------------------------------- GARMENT_ID = "parka_thrds" SEGMENTS = [ "seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r", "seg_arm_lower_l", "seg_arm_lower_r", "seg_hips", ] OFFSET_M = 0.026 # chunkiest standoff of the tops family — an insulated # parka worn over layers (jacket_modern is 0.022) THICKNESS_M = 0.008 # insulated cloth (jacket 5 mm, hoodie 6 mm) WELD_DIST = 5e-4 # coincident segment-seam weld tolerance (denim) # Hem: fraction of the pelvis->spine_01 span ABOVE the pelvis head. 0.15 lands # just below the hip joint (thigh head) on every body — hip-length, no skirt. HEM_PELVIS_FRAC = 0.15 HEM_ALLOW_FRAC = 0.50 # hem-ring search band above the cut plane (same span) # Sleeves: wrist cut + cuff band (jacket/hoodie practice — cuff anchored to the # ACTUAL post-cut mesh reach, the jagged cut stops 1-3 cm short of the plane). CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept CUFF_LEN_FRAC = 0.14 # cuff band length as fraction of the lowerarm # Collar / hood-down roll (fractions of neck_01 length unless noted). COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below the neck head COLLAR_X_MULT = 1.20 # parka collar wider than the tee band ROLL_OUT_FRAC = 0.50 # radial bulge magnitude (hoodie 0.40 — bulkier) ROLL_LIFT_FRAC = 0.16 # upward lift at the rim ROLL_Z_START_FRAC = 0.50 # roll influence starts this far below collar_z_min ROLL_REACH_COLLAR_X = 1.80 # candidate radius around neck axis (x tee collar_x) ROLL_FRONT_GAIN = 0.60 # bulge scale at the front... ROLL_BACK_GAIN = 1.25 # ...and the back (hood mass hangs behind) ROLL_EXPONENT = 1.80 # falloff sharpness toward the rim R_Z_DROP_FRONT = 0.20 # R region drop below collar_z_min (x neck_len) R_Z_DROP_BACK = 0.60 # asymmetric — the roll drapes lower on the back # Storm flap + painted zip (metric refs scale by shoulder |x| / 0.1919). FLAP_HALF_FRAC = 0.026 / 0.1919 # storm-flap half-width, frac of shoulder |x| TEETH_HALF_M_REF = 0.0065 # zip teeth strip half-width TEETH_PERIOD_M_REF = 0.026 # dash period along Z TEETH_DUTY_M_REF = 0.015 # bright dash length within a period # Big front patch pockets (x fracs of shoulder |x|, z fracs of garment span). POCKET_CX_FRAC = 0.44 POCKET_HW_FRAC = 0.25 POCKET_Z0_FRAC = 0.06 # clear of the hem drawcord band (HEM_BAND_FRAC 0.045) POCKET_Z1_FRAC = 0.255 POCKET_FLAP_FRAC = 0.30 # top fraction of the pocket = flap # Quilt channels (fracs of the garment span = collar_z_min - hem). QUILT_PERIOD_FRAC = 0.13 QUILT_HALFW_FRAC = 0.0070 # painted line half-width HEM_BAND_FRAC = 0.045 # drawcord hem band height PUFF_AMP = 0.045 # per-channel puff luminance amplitude # Left-breast logo patch (character-left = +X; the flap owns the centre line). # Same average_m absolutes as jacket_modern (0.035..0.115 m, z 1.30..1.42 m). CHEST_X_LO_FRAC = 0.035 / 0.1919 CHEST_X_HI_FRAC = 0.115 / 0.1919 CHEST_Z_LO_FRAC = (1.30 - 1.072) / (1.5205 - 1.072) CHEST_Z_HI_FRAC = (1.42 - 1.072) / (1.5205 - 1.072) # Painted luma palette (toon_garment recolors by luma: tint * (luma*1.5+0.2)). BASE_LUMA = 0.55 QUILT_LUMA = 0.40 # quilt channel lines ROLL_LUMA = 0.62 # hood roll reads lighter/lofted FLAP_LUMA = 0.50 # storm flap band POCKET_LUMA = 0.60 # pocket patch fill POCKET_FLAP_LUMA = 0.47 # pocket flap PATCH_LUMA = 0.68 # chest logo patch backing CUFF_LUMA = 0.46 # sleeve cuff bands HEM_LUMA = 0.48 # hem drawcord band STITCH_LUMA = 0.33 # stitch / outline lines TEETH_LUMA = 0.88 # bright zip teeth dashes TEETH_GAP_LUMA = 0.30 # dark zip tape between dashes ALBEDO_NOISE = 0.02 # woven jitter FRONT_EPS_M = 0.004 # front-facing gate on |y| (denim practice) # Deep blue-grey cold-weather hue (visible when the region mask is absent; # with the mask bound, identity is carried by luma alone). FABRIC_HUE = np.array([0.62, 0.70, 0.88], dtype=np.float32) TEX_SIZE = 1024 # albedo + mask (painted quilt lines need > 512) NOISE_SEED = 2093 _REF_SHOULDER_X = 0.1919 # average_m anchor (same as base/jacket) # Region labels -> mask channels (spec: roll+collar=R, body=G, sleeves=B, trim=A). LBL_G, LBL_R, LBL_B, LBL_A = 0, 1, 2, 3 LABEL_NAMES = ["body", "roll", "sleeve", "trim"] LABEL_RGBA_ARR = np.array([ (0.0, 1.0, 0.0, 0.0), # G body (1.0, 0.0, 0.0, 0.0), # R hood roll + collar (0.0, 0.0, 1.0, 0.0), # B sleeves (0.0, 0.0, 0.0, 1.0), # A pocket / quilt trim ], dtype=np.float32) # -------------------------------------------------------------------------- # Thresholds — base derivation + parka extras from the same armature # -------------------------------------------------------------------------- def parka_thresholds(armature): thr = base.derive_thresholds(armature) 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 = bone("upperarm_l") ua_r = bone("upperarm_r") neck = bone("neck_01") pelvis = bone("pelvis") spine01 = bone("spine_01") shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0 scale = shoulder_x / _REF_SHOULDER_X # Collar band (taller + wider than the tee) and hood-roll geometry. neck_len = neck.tail_local.z - neck.head_local.z tee_collar_x = thr["collar_x_abs"] # before the parka widening thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len thr["collar_x_abs"] = tee_collar_x * COLLAR_X_MULT thr["neck_y"] = neck.head_local.y thr["neck_len"] = neck_len thr["roll_out"] = ROLL_OUT_FRAC * neck_len thr["roll_lift"] = ROLL_LIFT_FRAC * neck_len thr["roll_reach"] = ROLL_REACH_COLLAR_X * tee_collar_x # Hip-length hem plane + ring-search allowance (bone-derived, per body). pz, sz = pelvis.head_local.z, spine01.head_local.z thr["hem_z"] = pz + HEM_PELVIS_FRAC * (sz - pz) thr["hem_allow"] = HEM_ALLOW_FRAC * (sz - pz) # Wrist cut planes on the lowerarm bones (jacket practice). cut_planes = [] lowerarm_len = 0.0 for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]: b = bone(bone_name) head_x, tail_x = b.head_local.x, b.tail_local.x cut_planes.append((sign, head_x + CUFF_KEEP_FRAC * (tail_x - head_x))) lowerarm_len = abs(tail_x - head_x) thr["wrist_cut_planes"] = cut_planes thr["cuff_len"] = CUFF_LEN_FRAC * lowerarm_len # Storm flap + zip teeth. thr["flap_half"] = shoulder_x * FLAP_HALF_FRAC thr["teeth_half"] = TEETH_HALF_M_REF * scale thr["teeth_period"] = TEETH_PERIOD_M_REF * scale thr["teeth_duty"] = TEETH_DUTY_M_REF * scale # Pockets (x now; z after the span is known in finalize_thresholds). thr["pocket_cx"] = POCKET_CX_FRAC * shoulder_x thr["pocket_hw"] = POCKET_HW_FRAC * shoulder_x # Left-breast logo patch (replaces the base full-chest box). thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC) spine_lo = spine01.head_local.z span = neck.head_local.z - spine_lo thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span, spine_lo + CHEST_Z_HI_FRAC * span) log(f"thresholds: hem z={thr['hem_z']:.3f} collar z>={thr['collar_z_min']:.3f} " f"|x|<{thr['collar_x_abs']:.3f} roll out={thr['roll_out']*1000:.0f}mm " f"reach={thr['roll_reach']:.3f} flap half={thr['flap_half']:.3f} " f"pocket cx={thr['pocket_cx']:.3f} hw={thr['pocket_hw']:.3f} " f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) " f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})") return thr def finalize_thresholds(shell, thr): """Mesh-measured extras after cuts/flatten/offset/roll: the garment span (drives quilt/pocket/hem-band proportions) and the cuff anchor from the sleeves' ACTUAL post-cut reach (hoodie practice).""" span = thr["collar_z_min"] - thr["hem_z"] thr["span"] = span thr["quilt_period"] = QUILT_PERIOD_FRAC * span thr["quilt_halfw"] = QUILT_HALFW_FRAC * span thr["hem_band_h"] = HEM_BAND_FRAC * span thr["pocket_z0"] = thr["hem_z"] + POCKET_Z0_FRAC * span thr["pocket_z1"] = thr["hem_z"] + POCKET_Z1_FRAC * span thr["pocket_flap_h"] = POCKET_FLAP_FRAC * (thr["pocket_z1"] - thr["pocket_z0"]) wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=0.0) thr["cuff_x0"] = wrist_x - thr["cuff_len"] log(f"finalized: span={span:.3f} quilt period={thr['quilt_period']*1000:.0f}mm " f"pocket z=({thr['pocket_z0']:.3f},{thr['pocket_z1']:.3f}) " f"cuff |x|>={thr['cuff_x0']:.3f} (reach {wrist_x:.3f})") # -------------------------------------------------------------------------- # Geometry: weld, cuts, hem flatten, hood roll # -------------------------------------------------------------------------- def weld_boundaries(shell): """Merge coincident segment-boundary verts (denim practice) so the offset can't open cracks at the torso|hips / torso|arm seams. Weights are identical by origin, 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) merged = before - len(bm.verts) bm.to_mesh(me) bm.free() me.update() log(f"welded segment boundaries: {merged} verts merged " f"({before} -> {len(me.vertices)})") def wrist_cut(shell, thr): """Delete forearm verts beyond the wrist plane on each side (jacket).""" bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() to_delete = [] for v in bm.verts: for sign, thr_x in thr["wrist_cut_planes"]: if sign > 0 and v.co.x > thr_x: to_delete.append(v) break if sign < 0 and v.co.x < thr_x: to_delete.append(v) break bmesh.ops.delete(bm, geom=to_delete, context='VERTS') bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"wrist cut removed {len(to_delete)} verts; " f"{len(shell.data.vertices)} remain") def hem_cut(shell, thr): """Trim everything below the hip-length hem plane (removes the seg_hips lower boundary, crotch and leg openings entirely).""" hem_z = thr["hem_z"] bm = bmesh.new() bm.from_mesh(shell.data) doomed = [v for v in bm.verts if v.co.z < hem_z] bmesh.ops.delete(bm, geom=doomed, context='VERTS') bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"hem cut at z={hem_z:.3f}: removed {len(doomed)} verts") def flatten_hem_rim(shell, thr): """Pull the open hem-ring verts onto the ring's own valley plane (denim flatten practice) — the vert-threshold cut leaves jagged teeth; a clean straight hem is what Solidify(use_rim) then caps. Only boundary verts in the hem band move; the neck and wrist rims sit far above `hem_allow`.""" me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.verts.ensure_lookup_table() bm.edges.ensure_lookup_table() limit = thr["hem_z"] + thr["hem_allow"] ring = set() for e in bm.edges: if len(e.link_faces) == 1: # open boundary for v in e.verts: if v.co.z < limit: ring.add(v.index) if not ring: log("WARNING: no hem-ring verts found — hem left jagged") bm.free() return zs = [bm.verts[i].co.z for i in ring] valley = min(zs) teeth = max(zs) - valley for i in ring: bm.verts[i].co.z = valley bm.to_mesh(me) bm.free() me.update() thr["hem_z"] = valley # paint anchors reference the clean rim log(f"flattened hem rim: {len(ring)} verts, teeth {teeth:.3f} m " f"-> plane z={valley:.3f}") def hood_roll(shell, thr): """Inflate collar-band verts radially away from the neck axis to read as a rolled-down hood (hoodie practice, parka-bulk parameters). Runs after the outward offset and before solidify so the roll gets cloth thickness.""" import math z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * thr["neck_len"] reach = thr["roll_reach"] me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.verts.ensure_lookup_table() candidates = [] z_rim = z_start for v in bm.verts: if v.co.z < z_start: continue hd = math.hypot(v.co.x, v.co.y - thr["neck_y"]) if hd > reach or hd < 1e-6: continue candidates.append((v, hd)) z_rim = max(z_rim, v.co.z) if z_rim <= z_start or not candidates: log("WARNING: no hood-roll candidates found — roll skipped") bm.free() return moved = 0 for v, hd in candidates: t = (v.co.z - z_start) / (z_rim - z_start) w = max(0.0, min(1.0, t)) ** ROLL_EXPONENT if w <= 0.0: continue dir_h = Vector((v.co.x, v.co.y - thr["neck_y"], 0.0)) / hd backness = 0.5 * (1.0 + dir_h.y * -base.FRONT_Y_SIGN) # +Y = back gain = ROLL_FRONT_GAIN + (ROLL_BACK_GAIN - ROLL_FRONT_GAIN) * backness v.co += dir_h * (thr["roll_out"] * w * gain) v.co.z += thr["roll_lift"] * w moved += 1 bm.to_mesh(me) bm.free() me.update() log(f"hood roll: {moved} verts inflated (rim z {z_rim:.3f}, " f"start z {z_start:.3f})") # -------------------------------------------------------------------------- # Feature field — ONE evaluation drives albedo luma AND region label together # -------------------------------------------------------------------------- def parka_field(px, py, pz, thr): """Evaluate the parka feature field at texel 3D positions (numpy arrays). Returns (label uint8 array indexing LABEL_RGBA_ARR, luma float32 array). """ n = px.shape[0] label = np.full(n, LBL_G, dtype=np.uint8) luma = np.full(n, BASE_LUMA, dtype=np.float32) ax = np.abs(px) front = py * base.FRONT_Y_SIGN > FRONT_EPS_M ew = thr["quilt_halfw"] # shared edge/stitch line half-width on_sleeve = ax >= thr["sleeve_x_abs"] label[on_sleeve] = LBL_B # --- zones ------------------------------------------------------------- hem = thr["hem_z"] hem_top = hem + thr["hem_band_h"] hem_band = pz <= hem_top cuff = ax >= thr["cuff_x0"] flap = front & (ax <= thr["flap_half"]) pocket_dx = np.abs(ax - thr["pocket_cx"]) in_pocket_z = (pz >= thr["pocket_z0"]) & (pz <= thr["pocket_z1"]) pocket = front & in_pocket_z & (pocket_dx <= thr["pocket_hw"]) m = ew * 3.0 # quilt keep-out margin around painted features pocket_pad = front & (pz >= thr["pocket_z0"] - m) & (pz <= thr["pocket_z1"] + m) \ & (pocket_dx <= thr["pocket_hw"] + m) x0, x1 = thr["chest_x"] z0, z1 = thr["chest_z"] patch = front & (px >= x0) & (px <= x1) & (pz >= z0) & (pz <= z1) patch_pad = front & (px >= x0 - m) & (px <= x1 + m) \ & (pz >= z0 - m) & (pz <= z1 + m) hd = np.hypot(px, py - thr["neck_y"]) roll_zmin = thr["collar_z_min"] \ - np.where(front, R_Z_DROP_FRONT, R_Z_DROP_BACK) * thr["neck_len"] # Sleeve texels never join the roll (hoodie precedence: sleeve check first) # — without the gate the deltoid caps classify as R on every body. roll = (pz >= roll_zmin) & ~on_sleeve \ & (hd <= thr["roll_reach"] + thr["roll_out"] + 0.01) # --- quilt channels (constant-z on the body, constant-|x| on sleeves) --- coord = np.where(on_sleeve, ax - thr["sleeve_x_abs"], pz - hem) period = thr["quilt_period"] tph = np.mod(coord, period) / period edge_d = np.minimum(tph, 1.0 - tph) * period quilt_ok = ~(hem_band | cuff | flap | pocket_pad | patch_pad | roll) qline = quilt_ok & (edge_d <= ew) puff = 4.0 * tph * (1.0 - tph) # --- luma (paint order = precedence, later writes win) ------------------ luma += np.where(quilt_ok, PUFF_AMP * (puff - 0.5) * 2.0, 0.0) luma[qline] = QUILT_LUMA luma[hem_band] = HEM_LUMA hemline = (np.abs(pz - hem_top) <= ew) & ~on_sleeve luma[hemline] = STITCH_LUMA luma[cuff] = CUFF_LUMA cuffline = np.abs(ax - thr["cuff_x0"]) <= ew luma[cuffline] = STITCH_LUMA # Big patch pockets: fill, flap, stitch outline. luma[pocket] = POCKET_LUMA flap_z = thr["pocket_z1"] - thr["pocket_flap_h"] luma[pocket & (pz >= flap_z)] = POCKET_FLAP_LUMA p_outline = pocket & ( (pocket_dx >= thr["pocket_hw"] - ew) | (pz <= thr["pocket_z0"] + ew) | (pz >= thr["pocket_z1"] - ew) | (np.abs(pz - flap_z) <= ew) ) luma[p_outline] = STITCH_LUMA # Chest logo patch backing (stays in G so the decal reads on body tint). luma[patch] = PATCH_LUMA patch_edge = patch & ( (px <= x0 + ew) | (px >= x1 - ew) | (pz <= z0 + ew) | (pz >= z1 - ew) ) luma[patch_edge] = STITCH_LUMA # Storm flap + zip teeth (teeth stop under the hood roll). luma[flap] = FLAP_LUMA flap_edge = front & (np.abs(ax - thr["flap_half"]) <= ew * 0.8) luma[flap_edge] = STITCH_LUMA teeth = front & (ax <= thr["teeth_half"]) & ~roll dash = np.mod(pz, thr["teeth_period"]) < thr["teeth_duty"] luma[teeth & dash] = TEETH_LUMA luma[teeth & ~dash] = TEETH_GAP_LUMA # Hood roll last: lofted read + a collar seam line right under it. rollseam = (np.abs(pz - roll_zmin) <= ew) & ~on_sleeve \ & (hd <= thr["roll_reach"] + thr["roll_out"] + 0.02) luma[rollseam & ~roll] = STITCH_LUMA luma[roll] = ROLL_LUMA # --- region labels (same masks; precedence: trim, then roll wins) ------- label[qline] = LBL_A label[cuff] = LBL_A label[pocket] = LBL_A label[flap] = LBL_A label[roll] = LBL_R return label, luma # -------------------------------------------------------------------------- # Combined albedo + mask bake (per-texel, pre-solidify — coverall practice) # -------------------------------------------------------------------------- def _tri_texels(a, b, c, W, H): """Texels covered by UV triangle (a,b,c) with barycentric weights (coverall rasterizer: returns ys, xs, w0, w1, w2).""" empty = (np.empty(0, int),) * 2 + (np.empty(0, np.float32),) * 3 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 empty denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy) if abs(denom) < 1e-9: return empty 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 empty return ( ys[inside], xs[inside], w0[inside].astype(np.float32), w1[inside].astype(np.float32), w2[inside].astype(np.float32), ) def bake_albedo_and_mask(shell, thr, out_dir, body, seed): """One pass over the UV0 faces: albedo and mask from a single shared feature-field evaluation per texel (denim practice). Baked PRE-solidify so there is exactly one face per texel (coverall practice).""" W = H = TEX_SIZE rng = np.random.default_rng(seed) noise_buf = (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE alb = np.empty((H, W, 4), dtype=np.float32) alb[:, :, 0:3] = np.clip( (BASE_LUMA + noise_buf)[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0) alb[:, :, 3] = 1.0 mask = np.zeros((H, W, 4), dtype=np.float32) mask[:, :, 1] = 1.0 # body-green background (bilinear-bleed safe) label_map = np.full((H, W), LBL_G, 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() if not len(bm.loops.layers.uv): raise RuntimeError("no UV layer for albedo/mask bake") uv_layer = bm.loops.layers.uv[0] tri_count = 0 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] for i in range(1, len(loops) - 1): tri = (0, i, i + 1) ys, xs, w0, w1, w2 = _tri_texels( uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H) if ys.size == 0: continue px3 = w0 * pos[tri[0]].x + w1 * pos[tri[1]].x + w2 * pos[tri[2]].x py3 = w0 * pos[tri[0]].y + w1 * pos[tri[1]].y + w2 * pos[tri[2]].y pz3 = w0 * pos[tri[0]].z + w1 * pos[tri[1]].z + w2 * pos[tri[2]].z lab, lum = parka_field(px3, py3, pz3, thr) lum = np.clip(lum + noise_buf[ys, xs], 0.0, 1.0) alb[ys, xs, 0:3] = np.clip( lum[:, None] * FABRIC_HUE[None, :], 0.0, 1.0) mask[ys, xs] = LABEL_RGBA_ARR[lab] label_map[ys, xs] = lab covered[ys, xs] = True tri_count += 1 bm.free() total = max(int(covered.sum()), 1) counts = np.bincount(label_map[covered], minlength=len(LABEL_NAMES)) log(f"painted {tri_count} UV triangles; region texels: " + " ".join( f"{LABEL_NAMES[i]}={int(c)} ({100.0 * c / total:.1f}%)" for i, c in enumerate(counts))) # Alpha floor (coverall lesson): Godot's fix_alpha_border import pass # rewrites RGB of fully-transparent texels — floor A at 2/255 everywhere. mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0) mask_path = os.path.join(out_dir, f"{body}_mask.png") img_mask = bpy.data.images.new(f"{GARMENT_ID}_mask_{body}", W, H, alpha=True) # Channel-packed DATA, not imagery: a straight-alpha PNG save would zero # the A (trim) channel (coverall lesson, verified there). 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_alb = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False) img_alb.pixels.foreach_set(alb.reshape(-1)) img_alb.update() return img_alb # -------------------------------------------------------------------------- # Per-body authoring # -------------------------------------------------------------------------- def author_parka(body_dir, out_dir, body, offset, seed): base.clear_scene() base.COVERED_SEGMENTS = SEGMENTS # build_covered_mesh reads this shell, armature = base.build_covered_mesh(body_dir) weld_boundaries(shell) thr = parka_thresholds(armature) wrist_cut(shell, thr) hem_cut(shell, thr) flatten_hem_rim(shell, thr) base.offset_outward(shell, offset) hood_roll(shell, thr) finalize_thresholds(shell, thr) # UV2 + bake BEFORE solidify (coverall practice: one face per texel). base.author_logo_uv(shell, thr) albedo_img = bake_albedo_and_mask(shell, thr, out_dir, body, seed) base.solidify(shell, THICKNESS_M) base.assign_fabric_material(shell, albedo_img) # Shared sidecar name before export: the glTF exporter names the embedded # image after the filepath basename and Godot extracts it as # _.png — this lands on the tshirt-convention # _base_albedo.png (coverall/jacket practice). base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png")) 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(): global HEM_PELVIS_FRAC 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]") 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_PELVIS_FRAC = float(argv[argv.index("--hem-frac") + 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"parka per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, " f"hem-frac {HEM_PELVIS_FRAC}") 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_parka(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}")) # Deterministic shared sidecars mirror the reference body (average_m). 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()