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

590 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):
reach blender run blender_author_hoodie \
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()