Files
settled-reach/tooling/scripts/blender/blender_author_jacket_shell.py
T
jpmschweitzerandClaude Opus 5.5 c597ec9131 docs(tooling): T-1253 — sweep the live references to retired tool paths
A script scanned every tracked doc, rule, skill, agent, hook and source file
for tooling/ paths that no longer exist, skipping historical records (sprints,
discussions, workshops, governance, generated wiki pages). It found 62. The
ones that tell a reader what to RUN now name the reach verb:

- The atlas skill still sent agents to tooling/atlas, atlas-verify,
  atlas-update-field and atlas-commit-and-sync — about forty lines, all
  retired in T-1285. They now name the `reach atlas` verbs, and the skill
  records that commit-and-sync STAGES by default (--commit to commit) and
  takes --corridor as an option.
- The clerk agent named tooling/clerk-review (now `reach dev clerk`). The Si
  and clerk briefings sent those agents to the retired tooling/db/decision
  and sqlite-query CLIs and to decisions/*.md paths that moved to
  governance/ in the pql migration. They now name pql.
- The ticket-cli rule documented `pql decisions read`, which does not exist;
  `show` already includes the body.
- The culture authoring guide and the RON sources name
  `reach validate ron`, with the same arguments as before.
- The 41 Blender payloads' usage lines ran the retired tooling/blender
  wrapper, and the docstrings still cited pre-carve-out paths. They now read
  `reach blender run <payload>`.
- Doc comments in server/, client/, wiki TOMLs and the domain modules.

What is left is deliberate: "Formerly …" provenance, dated plans and findings
docs, the retired-pipeline doc, and a build-artefact path.

project.yaml 0.4.14 (mirrored to the client). Comment-only, but four touched
files are in the canvas-version registry (trait_catalog_reader.rs, since
T-1289, canvas_sources.py itself, and two client files). The gate is
path-based and has no override. The previous push was rejected on exactly
this.

Three of the edits are stamped ledger sources, so systems.db is regenerated
and the stamp is fresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:13:58 +02:00

529 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.
reach blender run blender_author_jacket_shell \
<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()