Files
settled-reach/tooling/scripts/blender/blender_author_parka.py
T
jpmschweitzerandClaude Opus 5 201dabd19b refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
35 payloads move to tooling/scripts/blender/ and stay outside package scope.
They run under Blender's bundled Python, which cannot see the repo venv, so
they physically cannot import tooling.core — holding them to the D-263 contract
would either fail the gate forever or force the contract to be weakened for
everyone, and the second is how a gate stops meaning anything.

Count verified by import rather than filename: 33 import bpy/bmesh directly,
and the two that do not are still payloads per their own usage lines.
garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290.

The bash wrapper is retired rather than kept. Keeping it would have put the
install-resolution logic in two places, which is the duplication T-1286 had
just finished collapsing three copies of. domains/blender/service.py owns the
decisions — resolve_blender (native beats flatpak, ordering preserved),
resolve_payload, absolutise — and only run_payload performs. test_blender.py
pins all of them without launching Blender, which matters here more than
usual: the thing being launched is a 200 MB GUI application that writes GLBs.

`reach blender run` takes a registered payload name OR a path to any script,
because the wrapper served both — the spikes and the glb-gen skill hand it
one-off scripts of their own. An unknown name enumerates all 35 and exits 2.

The exclusion now defends itself. check_carve_out_stays_carved fails if
`scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty
exclusion proves nothing), or if an __init__.py appears there (which would make
the payloads importable — the coupling the carve-out exists to prevent). All
three arms mutation-proved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:55:52 +02:00

727 lines
30 KiB
Python

"""
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: <out_dir>/<body>.glb (skinned, painted albedo embedded)
<out_dir>/<body>_mask.png (RGBA region mask, that body's UV0)
<out_dir>/<body>_base_albedo.png
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
<out_dir>/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
# <glb>_<image>.png — this lands on the tshirt-convention
# <body>_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_root> <out_dir> [--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()