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

591 lines
23 KiB
Python

"""
blender_author_hoodie.py (T-1089 — hoodie_modern, per-body offset-shell)
Hooded-top companion to blender_author_offset_shell.py: reuses the base
module's segment join / bone-ratio thresholds / offset / solidify / logo-UV2 /
export machinery and layers the hoodie-specific geometry + texture identity on
top. Everything spatial is derived from bone landmarks (ratios of neck_01
length, shoulder |x|, spine span) so the same parameters produce a
proportionally identical garment on all 11 bodies. The parameter block below
is the reusable seam for the rest of the hooded/long-sleeve family
(track_jacket, sweater_modern): import this module and override.
Hoodie deltas over the base t-shirt shell:
* covers torso + torso_upper + FULL arms (long sleeves, wrist-cut on the
lowerarm bone instead of the upperarm short-sleeve cut)
* HOOD DOWN — a rolled collar bulk ring: collar-band verts are inflated
radially away from the neck axis (back-biased, slight upward lift) before
solidify, so the roll gets real thickness and caps into a ring
* looser standoff (16 mm vs the 12 mm per-body tee) + thicker cloth (6 mm)
* painted albedo identity (texture-carried, per body because UV0 face
classification is per body): kangaroo pocket fill + stitch outline,
drawstrings, ribbed hem + cuff bands — all painted as LUMINANCE detail so
the luma-preserving toon_garment recolor keeps them under any tint
* region mask: R = collar/hood roll (asymmetric drop — drapes lower on the
back), G = body, B = sleeves + kangaroo pocket
* logo-capable chest via the base UV2 channel (unchanged)
PER-BODY ONLY (Q-060: offset-shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_hoodie.py -- \
client/assets/characters/bodies client/assets/characters/clothing/hoodie_modern \
[--bodies a,b,c] [--offset M]
Writes per body: <out_dir>/<body>.glb, <body>_mask.png, <body>_base_albedo.png
Plus fallbacks: <out_dir>/base_albedo.png + reference_mask.png (= average_m's).
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
"""
import importlib.util
import math
import os
import shutil
import sys
import bpy
import bmesh
import numpy as np
from mathutils import Vector
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load_base():
spec = importlib.util.spec_from_file_location(
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
base = _load_base()
# --------------------------------------------------------------------------
# Hoodie parameters (the reusable seam — override for track_jacket/sweater)
# --------------------------------------------------------------------------
GARMENT_ID = "hoodie_modern"
SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
]
OFFSET_M = 0.016 # loose standoff (per-body construction guarantees it)
THICKNESS_M = 0.006 # fleece-weight cloth
WRIST_FRAC = 0.92 # fraction of the lowerarm kept (sleeve ends at wrist)
# Hood-down roll: fractions of neck_01 length unless noted.
ROLL_OUT_FRAC = 0.40 # radial bulge magnitude
ROLL_LIFT_FRAC = 0.14 # upward lift at the rim
ROLL_Z_START_FRAC = 0.45 # roll influence starts this far below collar_z_min
ROLL_REACH_COLLAR_X = 1.7 # candidate radius around the neck axis (x collar_x_abs)
ROLL_FRONT_GAIN = 0.55 # bulge scale at the front...
ROLL_BACK_GAIN = 1.10 # ...and at the back (hood mass hangs behind)
ROLL_EXPONENT = 1.7 # falloff sharpness toward the rim
# Region mask R (roll) band: drop below collar_z_min, x neck_len, per side.
R_Z_DROP_FRONT = 0.15
R_Z_DROP_BACK = 0.55
# Kangaroo pocket: z as fractions of the waistband-top -> chest_z_lo span
# (sits ABOVE the ribbed hem band), x as fractions of shoulder |x|.
POCKET_Z0_FRAC = 0.06
POCKET_Z1_FRAC = 0.88
POCKET_WB_FRAC = 0.56 # bottom half-width
POCKET_WT_FRAC = 0.30 # top half-width (diagonal hand openings)
POCKET_FILL_MULT = 0.95 # subtle patch shading
POCKET_LINE_MULT = 0.70 # stitch outline darkening
POCKET_LINE_PX = 2 # outline thickness (erosion iterations)
# Drawstrings (front only): x offset / half-width as fractions of collar_x_abs,
# z as fractions of neck_len.
STRING_X_FRAC = 0.30
STRING_HALF_W_M = 0.005
STRING_TOP_DROP_FRAC = 0.10
STRING_LEN_FRAC = 0.75
STRING_MULT = 0.50
# Ribbed bands: hem (x spine span) and cuffs (x lowerarm length). The cuff is
# anchored to the mesh's ACTUAL post-cut reach, not the nominal wrist plane —
# the vert-threshold cut leaves a jagged face boundary that stops 1-3 cm short
# of the plane, so a nominal-anchored band would mostly land on deleted faces.
HEM_BAND_FRAC = 0.08
CUFF_LEN_FRAC = 0.16
BAND_MULT = 0.85
BAND_LINE_MULT = 0.72
BAND_LINE_M = 0.004
# Muted street-tone fabric (luma drives the toon_garment recolor).
FABRIC_RGB = (0.55, 0.56, 0.58)
FABRIC_NOISE = 0.035
ALBEDO_SEED = 1091
def log(msg):
print(f"[hoodie] {msg}")
# --------------------------------------------------------------------------
# Landmarks beyond the base thresholds
# --------------------------------------------------------------------------
def derive_landmarks(armature, thr):
"""Hoodie-specific bone landmarks (all in body-local metres)."""
bones = armature.data.bones
neck = bones.get("neck_01")
la_l = bones.get("lowerarm_l")
la_r = bones.get("lowerarm_r")
if not all([neck, la_l, la_r]):
raise RuntimeError("landmark bones missing (neck_01 / lowerarm_l/r)")
neck_len = neck.tail_local.z - neck.head_local.z
lm = {
"neck_y": neck.head_local.y,
"neck_len": neck_len,
"roll_out": ROLL_OUT_FRAC * neck_len,
"roll_lift": ROLL_LIFT_FRAC * neck_len,
"roll_reach": ROLL_REACH_COLLAR_X * thr["collar_x_abs"],
# wrist cut thresholds per side: (sign, x threshold)
"wrist_cuts": [],
"lowerarm_len": 0.0,
}
for b, sign in [(la_l, +1), (la_r, -1)]:
head_x, tail_x = b.head_local.x, b.tail_local.x
thr_x = head_x + WRIST_FRAC * (tail_x - head_x)
lm["wrist_cuts"].append((sign, thr_x))
lm["lowerarm_len"] = abs(tail_x - head_x)
log(f"landmarks: neck_len {neck_len:.4f} roll_out {lm['roll_out']*1000:.0f}mm "
f"reach {lm['roll_reach']:.3f} wrist cuts "
+ " ".join(f"{s:+d}@{t:.3f}" for s, t in lm["wrist_cuts"]))
return lm
# --------------------------------------------------------------------------
# Geometry: wrist cut + hood roll
# --------------------------------------------------------------------------
def wrist_cut(shell, lm):
"""Trim the sleeve tubes at the wrist plane (same pattern as base.sleeve_cut,
but on the lowerarm bone so the sleeves stay full length)."""
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 lm["wrist_cuts"]:
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 hood_roll(shell, thr, lm):
"""Inflate collar-band verts radially away from the neck axis to read as a
rolled-down hood: back-biased bulge + slight rim lift. Runs after the
outward offset and before solidify (the roll then gets cloth thickness)."""
z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * lm["neck_len"]
reach = lm["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 - lm["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 - lm["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 * (lm["roll_out"] * w * gain)
v.co.z += lm["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})")
# --------------------------------------------------------------------------
# Paint maps: rasterize body-space (x, z) into UV0 texel space
# --------------------------------------------------------------------------
def _raster_tri_attr(maps, a, b, c, xs, zs, is_front, is_back, W, H):
"""Barycentric rasterization interpolating body-space x/z per texel."""
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, px_x = np.mgrid[miny:maxy + 1, minx:maxx + 1]
px = px_x + 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
x_val = w0 * xs[0] + w1 * xs[1] + w2 * xs[2]
z_val = w0 * zs[0] + w1 * zs[1] + w2 * zs[2]
sl = (slice(miny, maxy + 1), slice(minx, maxx + 1))
maps["X"][sl][inside] = x_val[inside]
maps["Z"][sl][inside] = z_val[inside]
maps["valid"][sl][inside] = True
if is_front:
maps["front"][sl][inside] = True
if is_back:
maps["back"][sl][inside] = True
def bake_paint_maps(shell, W, H):
"""Rasterize the shell's UV0 layout into per-texel body-space X/Z maps,
with front/back facing flags (front = -Y on this rig)."""
maps = {
"X": np.zeros((H, W), dtype=np.float32),
"Z": np.zeros((H, W), dtype=np.float32),
"valid": np.zeros((H, W), dtype=bool),
"front": np.zeros((H, W), dtype=bool),
"back": np.zeros((H, W), dtype=bool),
}
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.faces.ensure_lookup_table()
bm.normal_update()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for paint-map bake")
for face in bm.faces:
n_front = face.normal.y * base.FRONT_Y_SIGN
c_front = face.calc_center_median().y * base.FRONT_Y_SIGN
is_front = n_front > 0.15 and c_front > 0.0
is_back = n_front < -0.15 and c_front < 0.0
loops = face.loops[:]
uvs = [lp[uv_layer].uv.copy() for lp in loops]
cos = [lp.vert.co for lp in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_attr(
maps, uvs[0], uvs[i], uvs[i + 1],
(cos[0].x, cos[i].x, cos[i + 1].x),
(cos[0].z, cos[i].z, cos[i + 1].z),
is_front, is_back, W, H)
bm.free()
overlap = int((maps["front"] & maps["back"]).sum())
log(f"paint maps: {int(maps['valid'].sum())} texels "
f"(front {int(maps['front'].sum())}, back {int(maps['back'].sum())}, "
f"front/back UV overlap {overlap})")
dump = os.environ.get("HOODIE_DEBUG_MAPS", "")
if dump:
np.savez_compressed(dump, **maps)
log(f"debug maps dumped -> {dump}")
return maps
# --------------------------------------------------------------------------
# Painted albedo (texture-carried modern identity)
# --------------------------------------------------------------------------
def derive_paint_params(shell, thr, lm):
"""Body-space paint geometry, derived from thresholds + final shell mesh."""
sleeve_x = thr["sleeve_x_abs"]
hem_z = min((v.co.z for v in shell.data.vertices
if abs(v.co.x) < sleeve_x), default=1.0)
# Actual sleeve reach from the final mesh (jagged post-cut boundary).
wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=sleeve_x)
chest_lo = thr["chest_z"][0]
hem_band = HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
hem_top = hem_z + hem_band
span = chest_lo - hem_top
shoulder_x = thr["chest_x"][1] / base.CHEST_X_FRAC # invert base ratio
p = {
"hem_z": hem_z,
"hem_band": hem_band,
"pocket_z0": hem_top + POCKET_Z0_FRAC * span,
"pocket_z1": hem_top + POCKET_Z1_FRAC * span,
"pocket_wb": POCKET_WB_FRAC * shoulder_x,
"pocket_wt": POCKET_WT_FRAC * shoulder_x,
"string_x": STRING_X_FRAC * thr["collar_x_abs"],
"string_z_top": thr["collar_z_min"] - STRING_TOP_DROP_FRAC * lm["neck_len"],
"string_len": STRING_LEN_FRAC * lm["neck_len"],
"cuff_len": CUFF_LEN_FRAC * lm["lowerarm_len"],
"wrist_x": wrist_x,
"sleeve_x": sleeve_x,
}
log(f"paint params: hem {hem_z:.3f} pocket z ({p['pocket_z0']:.3f},"
f"{p['pocket_z1']:.3f}) w ({p['pocket_wt']:.3f}->{p['pocket_wb']:.3f})")
return p
def _erode(m, iters):
for _ in range(iters):
m = (m
& np.roll(m, 1, 0) & np.roll(m, -1, 0)
& np.roll(m, 1, 1) & np.roll(m, -1, 1))
return m
def pocket_mask(maps, p):
"""Kangaroo-pocket texel mask: front-only trapezoid with diagonal sides."""
X, Z = maps["X"], maps["Z"]
paintable = maps["front"] & ~maps["back"]
z0, z1 = p["pocket_z0"], p["pocket_z1"]
if z1 <= z0:
return np.zeros_like(paintable)
t = np.clip((z1 - Z) / (z1 - z0), 0.0, 1.0) # 1 at bottom, 0 at top
half_w = p["pocket_wt"] + (p["pocket_wb"] - p["pocket_wt"]) * t
inside = paintable & (Z >= z0) & (Z <= z1) & (np.abs(X) <= half_w)
log(f"pocket mask: {int(inside.sum())} texels")
return inside
def make_painted_albedo(maps, pocket_px, p, out_dir, body):
"""Flat street-tone fabric + painted luminance detail; returns bpy image."""
W = H = base.ALBEDO_SIZE
rng = np.random.default_rng(ALBEDO_SEED)
fabric = np.array(FABRIC_RGB, dtype=np.float32)
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE
rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
X, Z = maps["X"], maps["Z"]
valid = maps["valid"]
front_only = maps["front"] & ~maps["back"]
# Ribbed hem band (all around) + top stitch line.
hem_top = p["hem_z"] + p["hem_band"]
band = valid & (Z <= hem_top) & (np.abs(X) <= p["sleeve_x"])
rgb[band] *= BAND_MULT
line = valid & (np.abs(Z - hem_top) <= BAND_LINE_M) & (np.abs(X) <= p["sleeve_x"])
rgb[line] *= BAND_LINE_MULT
# Ribbed cuffs (all around) + inner stitch line.
cuff_x0 = p["wrist_x"] - p["cuff_len"]
cuff = valid & (np.abs(X) >= cuff_x0)
rgb[cuff] *= BAND_MULT
cline = valid & (np.abs(np.abs(X) - cuff_x0) <= BAND_LINE_M)
rgb[cline] *= BAND_LINE_MULT
log(f"paint counts: hem {int(band.sum())} hemline {int(line.sum())} "
f"cuff {int(cuff.sum())} cuffline {int(cline.sum())} "
f"(hem_top {hem_top:.3f}, cuff_x0 {cuff_x0:.3f})")
zs = Z[valid]
xs_v = np.abs(X[valid])
log(f"map ranges: Z [{zs.min():.3f},{zs.max():.3f}] "
f"|X| [{xs_v.min():.3f},{xs_v.max():.3f}] "
f"Z<=hem_top {int((zs <= hem_top).sum())} "
f"|X|>=cuff_x0 {int((xs_v >= cuff_x0).sum())}")
# Kangaroo pocket: subtle fill + dark stitch outline.
rgb[pocket_px] *= POCKET_FILL_MULT
outline = pocket_px & ~_erode(pocket_px, POCKET_LINE_PX)
rgb[outline] *= POCKET_LINE_MULT
# Drawstrings (front only, hanging from the collar).
z_top = p["string_z_top"]
z_bot = z_top - p["string_len"]
for sx in (+p["string_x"], -p["string_x"]):
s = front_only & (np.abs(X - sx) <= STRING_HALF_W_M) \
& (Z >= z_bot) & (Z <= z_top)
rgb[s] *= STRING_MULT
rgba = np.concatenate([rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
img = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False)
img.pixels.foreach_set(rgba.reshape(-1))
img.update()
sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
img.filepath_raw = sidecar
img.file_format = 'PNG'
img.save()
log(f"painted albedo -> {sidecar}")
# The glTF exporter names the embedded image after the file basename, and
# the Godot import EXTRACTS it as <glb>_<imagename>.png. Point the image at
# the shared base_albedo.png so the extraction lands exactly on the sidecar
# name above (<body>_base_albedo.png, same pixels — tshirt convention),
# instead of a doubled <body>_<body>_base_albedo.png. The shared file is
# re-pointed to the reference body's paint at the end of the run.
img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
img.save()
return img
# --------------------------------------------------------------------------
# Region mask: R = hood roll, G = body, B = sleeves + pocket
# --------------------------------------------------------------------------
def _classify_hoodie(center, thr, lm):
x, y, z = center.x, center.y, center.z
if abs(x) >= thr["sleeve_x_abs"]:
return (0.0, 0.0, 1.0, 0.0) # sleeves -> B
hd = math.hypot(x, y - lm["neck_y"])
is_front = y * base.FRONT_Y_SIGN > 0.0
drop = R_Z_DROP_FRONT if is_front else R_Z_DROP_BACK
z_min = thr["collar_z_min"] - drop * lm["neck_len"]
if z >= z_min and hd <= lm["roll_reach"] + lm["roll_out"] + 0.01:
return (1.0, 0.0, 0.0, 0.0) # hood roll -> R
return (0.0, 1.0, 0.0, 0.0) # body -> G
def bake_hoodie_mask(shell, out_path, thr, lm, pocket_px):
"""Per-face region rasterization (like base.bake_region_mask) with the
hoodie classifier, then the pocket texels overlaid into B."""
W = H = base.MASK_SIZE
buf = np.zeros((H, W, 4), dtype=np.float32)
buf[:, :, 1] = 1.0 # body-green background (bleed safety)
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.faces.ensure_lookup_table()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for region mask bake")
counts = {"roll": 0, "body": 0, "sleeve": 0}
for face in bm.faces:
color = _classify_hoodie(face.calc_center_median(), thr, lm)
if color[0] > 0.5:
counts["roll"] += 1
elif color[2] > 0.5:
counts["sleeve"] += 1
else:
counts["body"] += 1
for a, b, c in base._tris_from_face(face, uv_layer):
base._raster_tri(buf, a, b, c, color, W, H)
bm.free()
total = max(sum(counts.values()), 1)
log("region faces: " + " ".join(
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
# Pocket joins the sleeve tint region (B), per the spec.
if pocket_px is not None and pocket_px.shape == (H, W):
buf[pocket_px] = (0.0, 0.0, 1.0, 0.0)
img = bpy.data.images.new(f"{GARMENT_ID}_mask", W, H, alpha=True)
img.pixels.foreach_set(buf.reshape(-1))
img.update()
img.filepath_raw = out_path
img.file_format = 'PNG'
img.save()
log(f"baked region mask -> {out_path}")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_hoodie(body_dir, out_dir, body, offset):
base.clear_scene()
shell, armature = base.build_covered_mesh(body_dir)
thr = base.derive_thresholds(armature)
lm = derive_landmarks(armature, thr)
wrist_cut(shell, lm)
base.offset_outward(shell, offset)
hood_roll(shell, thr, lm)
base.solidify(shell, THICKNESS_M)
maps = bake_paint_maps(shell, base.ALBEDO_SIZE, base.ALBEDO_SIZE)
p = derive_paint_params(shell, thr, lm)
pocket_px = pocket_mask(maps, p)
albedo_img = make_painted_albedo(maps, pocket_px, p, out_dir, body)
base.assign_fabric_material(shell, albedo_img)
base.author_logo_uv(shell, thr)
# Mask texels are MASK_SIZE; paint maps are ALBEDO_SIZE. Sizes match (512)
# today; rebake the pocket mask if they ever diverge.
mask_pocket = pocket_px if base.MASK_SIZE == base.ALBEDO_SIZE else None
bake_hoodie_mask(shell, os.path.join(out_dir, f"{body}_mask.png"),
thr, lm, mask_pocket)
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]")
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])
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)
base.COVERED_SEGMENTS = SEGMENTS
log(f"per-body hoodie: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
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_hoodie(body_dir, out_dir, body, offset)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
# Runtime fallbacks mirror the reference body (average_m).
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
if os.path.isfile(ref_mask):
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
log("copied reference_mask.png fallback")
ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
if os.path.isfile(ref_albedo):
shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
log("copied base_albedo.png fallback")
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()