Files
settled-reach/tooling/scripts/blender/blender_author_jacket_shell.py
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

530 lines
22 KiB
Python

"""
blender_author_jacket_shell.py (T-1089, route (c) hand-author proof: suit jacket)
Jacket-family companion to blender_author_offset_shell.py — imports that module
as a library (segments -> join -> offset -> solidify -> skinned export are
reused unchanged) and adds what a JACKET needs beyond a t-shirt:
* full-arm coverage (torso + torso_upper + upper AND lower arms) with a
WRIST cut on the lowerarm bone (long sleeves) instead of an upper-arm cut;
* a boundary WELD after the segment join, so the elbow/shoulder segment
seams offset as one continuous surface (no gap rings on long sleeves);
* a jacket standoff (default 18 mm — outerwear drape over the 12 mm tee);
* a PAINTED albedo + region mask baked together in ONE per-pixel
rasterization pass: each texel's 3D body-local position is interpolated
barycentric-ally, so the V-opening / lapels / shirt triangle have crisp
edges independent of the low-poly tessellation (the base script's
per-FACE classification would read chunky on a chest V).
Region-mask convention for the suit (toon_garment.gdshader tint routing):
R = lapels + collar band (tint_0 — subtle satin two-tone)
G = jacket body (tint_1)
B = sleeves (tint_2)
A = shirt triangle in the V (tint_3 — default WHITE so outfits can
recolor the visible shirt)
Painted albedo details (luma-carried; the shader recolors hue per region but
keeps albedo luminance, so detail must live in the 0.53-max luma band or the
`luma*1.5+0.2` curve clips it): white-shirt triangle with placket + buttons,
lapel fold + edge shading, front closure seam + jacket button below the V,
sleeve cuff band. All geometry parameters are bone-landmark RATIOS (same
anchors as the base script: shoulder = upperarm head |x|, neck_01, spine_01,
plus lowerarm for the wrist), so every body derives its own proportional
cut/paint constants — calibrated to the intended metric sizes on average_m.
PER-BODY ONLY: offset-shell garments are authored per body (Q-060 route
guidance) — there is no single-reference mode here.
tooling/blender --background --python \
tooling/garment-fit/blender_author_jacket_shell.py -- \
<bodies_root> <out_dir> \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-frac 0.85]
Writes per body:
<out_dir>/<body>.glb skinned jacket authored on that body
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0)
Plus:
<out_dir>/base_albedo.png copy of average_m's painted albedo
<out_dir>/reference_mask.png copy of average_m's mask (runtime fallback)
The painted per-body albedo is embedded in each GLB (image URI basename
"base_albedo", staged under .cache/garment-fit-suit/<body>/); on import
Godot extracts it to <out_dir>/<body>_base_albedo.png — the same layout the
t-shirt reference garment has.
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
"""
import sys
import os
import shutil
import bpy
import bmesh
import numpy as np
# Import the base offset-shell module as a library.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import blender_author_offset_shell as base # noqa: E402
# --------------------------------------------------------------------------
# Jacket parameters
# --------------------------------------------------------------------------
JACKET_SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
]
JACKET_OFFSET_M = 0.018 # outerwear standoff (tee is 12 mm per-body)
WRIST_FRAC = 0.92 # fraction of lowerarm length kept. NB the vert-delete
# cut also drops faces touching deleted verts, so the
# visible sleeve ends ~1 low-poly ring earlier — 0.92
# lands the hem just above the wrist (matches the
# buttondown's calibrated SLEEVE_END_FRAC).
CUFF_FRAC = 0.14 # last fraction of the KEPT sleeve painted as cuff band
WELD_DIST_M = 0.0001 # merge distance for segment-boundary weld
BAKE_SIZE = 1024 # albedo + mask resolution (512 is too coarse for the
# 6 mm placket / button dots on the torso island)
# --- V-opening / lapel geometry, as ratios of average_m bone landmarks -----
# (anchors identical to base: shoulder |x| 0.1919, neck head z 1.5205,
# spine_01 head z 1.072 -> spine span 0.4485)
V_HALF_TOP_M = 0.068 # V half-width at the collar on average_m
V_BOT_Z_M = 1.17 # V bottom (jacket closure point) on average_m
LAPEL_W_M = 0.045 # lapel band width
LAPEL_DROP_M = 0.020 # lapel wraps slightly below the V point
EDGE_W_M = 0.011 # lapel fold/edge shading line width (albedo only)
PLACKET_HALF_M = 0.006 # shirt placket half-width
CLOSURE_HALF_M = 0.0045 # jacket front closure seam half-width
SHIRT_BTN_R_M = 0.0055 # shirt button radius
JACKET_BTN_R_M = 0.009 # jacket button radius
JACKET_BTN_DROP_M = 0.030 # jacket button sits this far below the V point
SHIRT_BTN_FRACS = (0.11, 0.30) # shirt button z, as frac of (v_top - v_bot)
V_HALF_TOP_FRAC = V_HALF_TOP_M / base._REF_SHOULDER_X
LAPEL_W_FRAC = LAPEL_W_M / base._REF_SHOULDER_X
EDGE_W_FRAC = EDGE_W_M / base._REF_SHOULDER_X
PLACKET_HALF_FRAC = PLACKET_HALF_M / base._REF_SHOULDER_X
CLOSURE_HALF_FRAC = CLOSURE_HALF_M / base._REF_SHOULDER_X
SHIRT_BTN_R_FRAC = SHIRT_BTN_R_M / base._REF_SHOULDER_X
JACKET_BTN_R_FRAC = JACKET_BTN_R_M / base._REF_SHOULDER_X
_REF_SPAN = base._REF_NECK_Z - base._REF_SPINE_LO_Z
V_BOT_FRAC = (V_BOT_Z_M - base._REF_SPINE_LO_Z) / _REF_SPAN
LAPEL_DROP_FRAC = LAPEL_DROP_M / _REF_SPAN
JACKET_BTN_DROP_FRAC = JACKET_BTN_DROP_M / _REF_SPAN
# --- Painted albedo luma palette (neutral hue; shader tints supply color) ---
# Kept inside the luma*1.5+0.2 <= 1.0 budget (luma <= 0.53) so painted detail
# survives a white tint on the shirt region.
LUMA_FABRIC = 0.400 # jacket body + sleeves + back
LUMA_SATIN = 0.475 # lapels + collar band (subtle two-tone vs body)
LUMA_EDGE = 0.250 # lapel fold/edge shading lines
LUMA_SHIRT = 0.520 # shirt triangle (renders ~white under white tint)
LUMA_PLACKET = 0.440 # shirt placket strip
LUMA_SHIRT_BTN = 0.320 # shirt buttons
LUMA_CLOSURE = 0.290 # jacket closure seam below the V
LUMA_JACKET_BTN = 0.180 # jacket button
LUMA_CUFF = 0.330 # sleeve cuff band
ALBEDO_NOISE = 0.018 # +/- woven jitter (4-px blocks — compresses well)
ALBEDO_TINT = (1.0, 1.0, 1.03) # slightly cool cast on the neutral greys
log = base.log
# --------------------------------------------------------------------------
# Suit-specific threshold derivation (extends base.derive_thresholds)
# --------------------------------------------------------------------------
def derive_jacket_thresholds(armature, wrist_frac):
thr = base.derive_thresholds(armature)
bones = armature.data.bones
ua_l = bones.get("upperarm_l")
neck = bones.get("neck_01")
spine01 = bones.get("spine_01")
if ua_l and neck and spine01:
ua_r = bones.get("upperarm_r")
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
spine_lo = spine01.head_local.z
span = neck.head_local.z - spine_lo
else:
log("WARNING: landmark bones missing — legacy average_m jacket constants")
shoulder_x = base._REF_SHOULDER_X
spine_lo = base._REF_SPINE_LO_Z
span = _REF_SPAN
v_top = thr["collar_z_min"]
v_bot = spine_lo + V_BOT_FRAC * span
thr.update({
"v_top_z": v_top,
"v_bot_z": v_bot,
"v_half_top": shoulder_x * V_HALF_TOP_FRAC,
"lapel_w": shoulder_x * LAPEL_W_FRAC,
"lapel_drop": LAPEL_DROP_FRAC * span,
"edge_w": shoulder_x * EDGE_W_FRAC,
"placket_half": shoulder_x * PLACKET_HALF_FRAC,
"closure_half": shoulder_x * CLOSURE_HALF_FRAC,
"shirt_btn_r": shoulder_x * SHIRT_BTN_R_FRAC,
"jacket_btn_r": shoulder_x * JACKET_BTN_R_FRAC,
"jacket_btn_z": v_bot - JACKET_BTN_DROP_FRAC * span,
"shirt_btn_zs": [v_bot + f * (v_top - v_bot) for f in SHIRT_BTN_FRACS],
})
# Wrist cut planes + cuff band start, from the lowerarm bones (arms run
# along +/-X in rest pose, elbow head -> wrist tail).
cut_planes = [] # (sign, wrist_thr_x, cuff_start_x)
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
b = bones.get(bone_name)
if b is None:
log(f"WARNING: bone {bone_name} missing — sleeve uncut on that side")
continue
head_x = b.head_local.x
tail_x = b.tail_local.x
wrist_thr = head_x + wrist_frac * (tail_x - head_x)
cuff_start = head_x + wrist_frac * (1.0 - CUFF_FRAC) * (tail_x - head_x)
cut_planes.append((sign, wrist_thr, cuff_start))
log(f"wrist cut {bone_name}: keep |x| to {wrist_thr:.3f} "
f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {cuff_start:.3f}")
thr["wrist_planes"] = cut_planes
log(f"jacket thresholds: V top z {v_top:.3f} bottom z {v_bot:.3f} "
f"half-top {thr['v_half_top']:.3f} lapel {thr['lapel_w']:.3f} "
f"btn z {thr['jacket_btn_z']:.3f}")
return thr
# --------------------------------------------------------------------------
# Geometry: weld + wrist cut
# --------------------------------------------------------------------------
def weld_segment_boundaries(shell):
"""Merge coincident verts along segment seams (elbow/shoulder/chest lines).
Adjacent body segments duplicate their shared boundary loop; unwelded, the
two copies get island-local normals and offset APART, opening a gap ring at
every seam. Welding first makes the shell offset as one surface. Merged
verts come from the same original body vertex, so weights and loop UVs are
identical/preserved.
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST_M)
bm.to_mesh(shell.data)
after = len(shell.data.vertices)
bm.free()
shell.data.update()
log(f"welded segment boundaries: {before} -> {after} verts "
f"({before - after} merged)")
def wrist_cut(shell, thr):
"""Delete sleeve verts beyond the wrist plane on each lower arm."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = []
for v in bm.verts:
for sign, wrist_thr, _cuff in thr["wrist_planes"]:
if sign > 0 and v.co.x > wrist_thr:
to_delete.append(v)
break
if sign < 0 and v.co.x < wrist_thr:
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")
# --------------------------------------------------------------------------
# Combined per-pixel albedo paint + region mask bake
# --------------------------------------------------------------------------
def _classify_pixels(X, Y, Z, face_sleeve, face_side, thr):
"""Vectorized region + luma classification for one triangle's texels.
Returns (mask_rgba[N,4], luma[N]) for interpolated body-local positions.
Region priority: collar > shirt > lapel > body; sleeves are per-face.
"""
n = X.shape[0]
mask = np.zeros((n, 4), dtype=np.float32)
luma = np.full(n, LUMA_FABRIC, dtype=np.float32)
front = (Y * base.FRONT_Y_SIGN) > 0.010
ax = np.abs(X)
if face_sleeve:
mask[:, 2] = 1.0 # B = sleeves
# cuff band on this arm's outer end
for sign, wrist_thr, cuff_start in thr["wrist_planes"]:
if sign != face_side:
continue
in_cuff = (X * sign) >= (cuff_start * sign)
luma[in_cuff] = LUMA_CUFF
return mask, luma
v_top = thr["v_top_z"]
v_bot = thr["v_bot_z"]
# V half-width narrows linearly from v_half_top at the collar to 0 at v_bot.
t = np.clip((Z - v_bot) / max(v_top - v_bot, 1e-6), 0.0, None)
vhalf = thr["v_half_top"] * t
collar = (Z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"])
shirt = front & ~collar & (Z >= v_bot) & (Z < v_top) & (ax < vhalf)
lapel_out = vhalf + thr["lapel_w"]
lapel = (front & ~collar & ~shirt
& (Z >= v_bot - thr["lapel_drop"]) & (Z < v_top)
& (ax >= vhalf) & (ax < lapel_out))
mask[:, 0][collar | lapel] = 1.0 # R = lapels + collar band
mask[:, 3][shirt] = 1.0 # A = shirt triangle
body = ~(collar | shirt | lapel)
mask[:, 1][body] = 1.0 # G = jacket body
# ---- painted luma detail ----
luma[collar | lapel] = LUMA_SATIN
# lapel fold (inner) + edge (outer) shading lines
edge = lapel & ((ax < vhalf + thr["edge_w"]) | (ax > lapel_out - thr["edge_w"]))
luma[edge] = LUMA_EDGE
# shirt triangle: base, placket, buttons
luma[shirt] = LUMA_SHIRT
luma[shirt & (ax < thr["placket_half"])] = LUMA_PLACKET
for bz in thr["shirt_btn_zs"]:
btn = shirt & ((X ** 2 + (Z - bz) ** 2) < thr["shirt_btn_r"] ** 2)
luma[btn] = LUMA_SHIRT_BTN
# jacket closure seam + button below the V point
closure = body & front & (Z < v_bot) & (ax < thr["closure_half"])
luma[closure] = LUMA_CLOSURE
jbtn = front & ((X ** 2 + (Z - thr["jacket_btn_z"]) ** 2)
< thr["jacket_btn_r"] ** 2)
luma[jbtn] = LUMA_JACKET_BTN
return mask, luma
def bake_albedo_and_mask(shell, thr, albedo_path, mask_path, albedo_name):
"""One rasterization pass -> painted albedo PNG + RGBA region mask PNG.
Per texel the 3D body-local position is barycentric-interpolated, so paint
and regions are classified per PIXEL (crisp V edges on coarse topology).
Mask background = body green (bilinear bleed stays tinted); albedo
background = fabric luma.
"""
W = H = BAKE_SIZE
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
mask_buf[:, :, 1] = 1.0
albedo_buf = np.full((H, W), LUMA_FABRIC, dtype=np.float32)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for bake")
counts = {"collar_lapel": 0, "body": 0, "sleeve": 0, "shirt": 0}
for face in bm.faces:
center = face.calc_center_median()
face_sleeve = abs(center.x) >= thr["sleeve_x_abs"]
face_side = 1 if center.x >= 0.0 else -1
loops = face.loops[:]
uvs = [lo[uv_layer].uv for lo in loops]
cos = [lo.vert.co for lo in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_pos(
mask_buf, albedo_buf,
uvs[0], uvs[i], uvs[i + 1],
cos[0], cos[i], cos[i + 1],
face_sleeve, face_side, thr, W, H, counts)
bm.free()
log("bake texel classes: " + " ".join(f"{k}={v}" for k, v in counts.items()))
# ---- save mask ----
img = bpy.data.images.new("jacket_region_mask", W, H, alpha=True)
img.pixels.foreach_set(mask_buf.reshape(-1))
img.update()
img.filepath_raw = mask_path
img.file_format = 'PNG'
img.save()
log(f"baked region mask -> {mask_path}")
# ---- albedo: neutral grey luma + blocky woven noise, slightly cool ----
rng = np.random.default_rng(1089)
coarse = (rng.random((H // 4, W // 4), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
noise = np.kron(coarse, np.ones((4, 4), dtype=np.float32))
lum = np.clip(albedo_buf + noise, 0.0, 1.0)
rgba = np.empty((H, W, 4), dtype=np.float32)
for c, mul in enumerate(ALBEDO_TINT):
rgba[:, :, c] = np.clip(lum * mul, 0.0, 1.0)
rgba[:, :, 3] = 1.0
aimg = bpy.data.images.new(albedo_name, W, H, alpha=False)
aimg.pixels.foreach_set(rgba.reshape(-1))
aimg.update()
aimg.filepath_raw = albedo_path
aimg.file_format = 'PNG'
aimg.save()
log(f"painted albedo -> {albedo_path}")
return aimg
def _raster_tri_pos(mask_buf, albedo_buf, a, b, c, pa, pb, pc,
face_sleeve, face_side, thr, W, H, counts):
"""Barycentric fill interpolating 3D position for per-pixel classification."""
ax_, ay_ = a.x * (W - 1), a.y * (H - 1)
bx_, by_ = b.x * (W - 1), b.y * (H - 1)
cx_, cy_ = c.x * (W - 1), c.y * (H - 1)
minx = max(int(np.floor(min(ax_, bx_, cx_))), 0)
maxx = min(int(np.ceil(max(ax_, bx_, cx_))), W - 1)
miny = max(int(np.floor(min(ay_, by_, cy_))), 0)
maxy = min(int(np.ceil(max(ay_, by_, cy_))), H - 1)
if minx > maxx or miny > maxy:
return
denom = (by_ - cy_) * (ax_ - cx_) + (cx_ - bx_) * (ay_ - cy_)
if abs(denom) < 1e-9:
return
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
px = xs + 0.5
py = ys + 0.5
w0 = ((by_ - cy_) * (px - cx_) + (cx_ - bx_) * (py - cy_)) / denom
w1 = ((cy_ - ay_) * (px - cx_) + (ax_ - cx_) * (py - cy_)) / denom
w2 = 1.0 - w0 - w1
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
if not inside.any():
return
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
X = w0i * pa.x + w1i * pb.x + w2i * pc.x
Y = w0i * pa.y + w1i * pb.y + w2i * pc.y
Z = w0i * pa.z + w1i * pb.z + w2i * pc.z
mask_px, luma_px = _classify_pixels(
X.astype(np.float32), Y.astype(np.float32), Z.astype(np.float32),
face_sleeve, face_side, thr)
counts["collar_lapel"] += int((mask_px[:, 0] > 0.5).sum())
counts["shirt"] += int((mask_px[:, 3] > 0.5).sum())
counts["sleeve"] += int((mask_px[:, 2] > 0.5).sum())
counts["body"] += int((mask_px[:, 1] > 0.5).sum())
m_region = mask_buf[miny:maxy + 1, minx:maxx + 1, :]
a_region = albedo_buf[miny:maxy + 1, minx:maxx + 1]
m_region[inside] = mask_px
a_region[inside] = luma_px
# --------------------------------------------------------------------------
# Parked UV2 (suit is not logo-capable; keep mesh format pipeline-consistent)
# --------------------------------------------------------------------------
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("UV2 authored fully parked (non-logo garment)")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac):
base.clear_scene()
base.COVERED_SEGMENTS = JACKET_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
thr = derive_jacket_thresholds(armature, wrist_frac)
weld_segment_boundaries(shell)
wrist_cut(shell, thr)
base.offset_outward(shell, offset)
base.solidify(shell, base.CLOTH_THICKNESS_M)
# Stage the painted albedo OUTSIDE the Godot project as "base_albedo.png"
# — the glTF image URI takes the file basename, so Godot's on-import
# extraction lands at <out_dir>/<body>_base_albedo.png (tshirt layout)
# without a duplicate authored sidecar next to it.
body_stage = os.path.join(stage_dir, body)
os.makedirs(body_stage, exist_ok=True)
albedo_img = bake_albedo_and_mask(
shell, thr,
os.path.join(body_stage, "base_albedo.png"),
os.path.join(out_dir, f"{body}_mask.png"),
f"jacket_albedo_{body}")
base.assign_fabric_material(shell, albedo_img)
author_parked_uv2(shell)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
def main():
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] [--wrist-frac F]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = JACKET_OFFSET_M
wrist_frac = WRIST_FRAC
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--wrist-frac" in argv:
wrist_frac = float(argv[argv.index("--wrist-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)
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
stage_dir = os.path.join(repo_root, ".cache", "garment-fit-suit")
log(f"jacket per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
f"wrist frac {wrist_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_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {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(stage_dir, 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 staged {ref} albedo -> base_albedo.png")
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()