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>
446 lines
19 KiB
Python
446 lines
19 KiB
Python
"""
|
|
blender_author_outerwear.py (T-1089, jacket_modern — open-front outerwear family)
|
|
|
|
Companion to blender_author_offset_shell.py: authors OUTERWEAR offset-shells
|
|
(jacket / track-jacket / parka family) per body. It imports the base module and
|
|
reuses its scene/join/offset/solidify/mask/logo/export machinery; everything
|
|
garment-specific lives in the PARAMS block below, so a new outerwear piece is a
|
|
parameter delta, not a new script.
|
|
|
|
What it adds over the base t-shirt shell:
|
|
|
|
* FULL SLEEVES — covers seg_arm_lower_l/r too; the sleeve cut moves from the
|
|
upper arm to a WRIST cut on the lowerarm bones (same bone-plane technique).
|
|
* 4th REGION (A = zip/trim) — a front zip placket strip running hem -> through
|
|
the collar, plus knit cuff bands on the sleeve ends. Region layout per spec:
|
|
collar=R, body=G, sleeves=B, zip/trim=A.
|
|
* POSITION-PAINTED ALBEDO — instead of the base script's flat-noise albedo,
|
|
each body gets an albedo baked in its own UV0 layout by rasterizing every
|
|
face and painting per-texel from interpolated body-local position: zip teeth
|
|
dashes + placket, panel seam lines (shoulder, collar, cuff, placket edges),
|
|
hem band. The toon_garment shader recolors by LUMA, so the painted detail is
|
|
authored as luma contrast and survives any tint choice.
|
|
* LEFT-BREAST LOGO PATCH — the UV2 chest box shifts off-centre (the zip owns
|
|
the centre line), scaled per body from the same bone-landmark ratios.
|
|
|
|
Per-body only (this family is the poster child for the Q-060 per-body route):
|
|
|
|
tooling/blender --background --python \
|
|
tooling/garment-fit/blender_author_outerwear.py -- \
|
|
<bodies_root> <out_dir> \
|
|
[--bodies a,b,c] [--offset 0.022] [--cuff-keep 0.88]
|
|
|
|
Writes per body: <out_dir>/<body>.glb (painted albedo embedded), <body>_mask.png
|
|
Plus: <out_dir>/base_albedo.png (average_m's painted albedo)
|
|
<out_dir>/reference_mask.png (copy of average_m's, fallback)
|
|
|
|
The per-body albedo is embedded in the GLB under the image name "base_albedo"
|
|
(tshirt convention): Godot's importer extracts it as <body>_base_albedo.png.
|
|
average_m is deliberately authored LAST so the shared base_albedo.png sidecar
|
|
ends up holding the reference body's pixels.
|
|
|
|
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
|
"""
|
|
|
|
import importlib.util
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import bpy
|
|
import bmesh
|
|
import numpy as np
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
log = base.log
|
|
|
|
# --------------------------------------------------------------------------
|
|
# PARAMS — jacket_modern (casual zip jacket). A new outerwear garment should
|
|
# only need to touch this block (or override via CLI where exposed).
|
|
# --------------------------------------------------------------------------
|
|
|
|
# Full-arm coverage: torso + torso_upper + both whole arms.
|
|
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.022 # LARGEST standoff of the tops — worn over a shirt
|
|
# (tshirt_modern outer surface ~16 mm), must neither
|
|
# clip the body nor read skin-tight.
|
|
CLOTH_THICKNESS_M = 0.005 # beefier cloth than the 4 mm tee
|
|
|
|
CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept (wrist cut — hands show)
|
|
CUFF_BAND_FRAC = 0.22 # last fraction of the KEPT forearm = knit cuff trim (A)
|
|
|
|
# Region-shape ratios, anchored to the base module's average_m reference
|
|
# landmarks (shoulder |x| 0.1919, neck len 0.0793) so they scale per body.
|
|
ZIP_HALF_FRAC = 0.030 / 0.1919 # zip placket half-width as frac of shoulder |x|
|
|
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below neck head
|
|
# (t-shirt band was 0.3846 — jacket collar is taller,
|
|
# but 0.80 caught upper-chest faces and rendered as
|
|
# jagged shoulder "wings"; 0.55 keeps a neat band)
|
|
COLLAR_X_MULT = 1.15 # jacket collar slightly wider than the tee band
|
|
|
|
# Left-breast logo patch (character-left = +X; centre line belongs to the zip).
|
|
# Fracs of shoulder |x| / spine span, from average_m absolutes (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-albedo luma palette (the shader tints by luma: tint * (luma*1.5+0.2)).
|
|
BASE_LUMA = 0.58
|
|
PLACKET_LUMA = 0.50 # slightly darker front placket band
|
|
SEAM_LUMA = 0.38 # panel seam / stitch lines
|
|
TEETH_LUMA = 0.88 # bright zip teeth dashes
|
|
TEETH_GAP_LUMA = 0.34 # dark tape between dashes
|
|
CUFF_LUMA = 0.48 # knit cuff band
|
|
HEM_LUMA = 0.50 # bottom hem band
|
|
ALBEDO_NOISE = 0.02 # woven jitter
|
|
SEAM_W = 0.005 # seam line half-width, metres
|
|
TEETH_HALF_W = 0.006 # zip teeth strip half-width, metres
|
|
TEETH_PERIOD = 0.024 # dash period along Z, metres
|
|
TEETH_DUTY = 0.014 # bright dash length within a period, metres
|
|
HEM_BAND_M = 0.020 # hem band height, metres
|
|
# Slate hue applied on top of luma (only visible if the region mask is missing).
|
|
FABRIC_HUE = np.array([0.93, 0.97, 1.06], dtype=np.float32)
|
|
|
|
ALBEDO_SIZE = 512
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Thresholds — base derivation + outerwear extras from the same armature
|
|
# --------------------------------------------------------------------------
|
|
|
|
def outerwear_thresholds(armature):
|
|
thr = base.derive_thresholds(armature)
|
|
bones = armature.data.bones
|
|
neck = bones.get("neck_01")
|
|
ua_l = bones.get("upperarm_l")
|
|
ua_r = bones.get("upperarm_r")
|
|
spine01 = bones.get("spine_01")
|
|
|
|
# Shoulder |x| (same landmark the base derivation uses).
|
|
if ua_l and ua_r:
|
|
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
|
else:
|
|
shoulder_x = 0.1919 # average_m fallback
|
|
|
|
# Taller, wider standing collar.
|
|
if neck:
|
|
neck_len = neck.tail_local.z - neck.head_local.z
|
|
thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
|
|
thr["collar_x_abs"] = thr["collar_x_abs"] * COLLAR_X_MULT
|
|
|
|
# Front zip placket.
|
|
thr["zip_half_x"] = shoulder_x * ZIP_HALF_FRAC
|
|
|
|
# Wrist cut planes + cuff trim band on the lowerarm bones.
|
|
cut_planes = []
|
|
cuff_abs = []
|
|
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 left full-length")
|
|
continue
|
|
head_x = b.head_local.x
|
|
tail_x = b.tail_local.x
|
|
cut_x = head_x + CUFF_KEEP_FRAC * (tail_x - head_x)
|
|
band_x = head_x + (CUFF_KEEP_FRAC - CUFF_BAND_FRAC) * (tail_x - head_x)
|
|
cut_planes.append((sign, cut_x))
|
|
cuff_abs.append(abs(band_x))
|
|
log(f"wrist cut {bone_name}: keep |x| up to {cut_x:.3f} "
|
|
f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {band_x:.3f}")
|
|
thr["wrist_cut_planes"] = cut_planes
|
|
thr["cuff_x_abs"] = min(cuff_abs) if cuff_abs else 1e9
|
|
|
|
# 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)
|
|
if neck and spine01:
|
|
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"outerwear thresholds: zip half {thr['zip_half_x']:.3f} "
|
|
f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_x_abs']:.3f} "
|
|
f"cuff |x|>={thr['cuff_x_abs']:.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
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Wrist cut (bone-plane, same technique as the base sleeve cut)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def wrist_cut(shell, thr):
|
|
"""Delete forearm verts beyond the wrist plane on each side."""
|
|
cut_planes = thr.get("wrist_cut_planes", [])
|
|
if not cut_planes:
|
|
log("WARNING: no wrist cut planes — sleeves stay full length")
|
|
return
|
|
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 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")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 4-region classifier (collar=R, body=G, sleeves=B, zip/trim=A)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def classify_region_outerwear(center, thr):
|
|
x, y, z = center.x, center.y, center.z
|
|
ax = abs(x)
|
|
front = y * base.FRONT_Y_SIGN > 0.0
|
|
# Zip placket first: runs hem -> THROUGH the collar (zip-through jacket).
|
|
if front and ax < thr["zip_half_x"]:
|
|
return (0.0, 0.0, 0.0, 1.0) # zip/trim -> A (tint[3])
|
|
if ax >= thr["cuff_x_abs"]:
|
|
return (0.0, 0.0, 0.0, 1.0) # knit cuff trim -> A (tint[3])
|
|
if ax >= thr["sleeve_x_abs"]:
|
|
return (0.0, 0.0, 1.0, 0.0) # sleeves -> B (tint[2])
|
|
if z >= thr["collar_z_min"] and ax < thr["collar_x_abs"]:
|
|
return (1.0, 0.0, 0.0, 0.0) # collar -> R (tint[0])
|
|
return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1])
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Position-painted albedo (per-body UV0 bake)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _paint_luma(x, y, z, thr, z_min):
|
|
"""Vectorised luma paint from body-local position (arrays in, array out)."""
|
|
v = np.full(x.shape, BASE_LUMA, dtype=np.float32)
|
|
ax = np.abs(x)
|
|
front = (y * base.FRONT_Y_SIGN) > 0.0
|
|
|
|
# Hem band along the jacket bottom.
|
|
v = np.where(z < z_min + HEM_BAND_M, HEM_LUMA, v)
|
|
# Knit cuff bands.
|
|
v = np.where(ax >= thr["cuff_x_abs"], CUFF_LUMA, v)
|
|
# Panel seam lines: shoulder (sleeve boundary), cuff start, collar base.
|
|
v = np.where(np.abs(ax - thr["sleeve_x_abs"]) < SEAM_W, SEAM_LUMA, v)
|
|
v = np.where(np.abs(ax - thr["cuff_x_abs"]) < SEAM_W, SEAM_LUMA, v)
|
|
collar_seam = (np.abs(z - thr["collar_z_min"]) < SEAM_W) \
|
|
& (ax < thr["collar_x_abs"] * 1.2)
|
|
v = np.where(collar_seam, SEAM_LUMA, v)
|
|
|
|
# Front zip placket: darker band + stitch edges + dashed teeth line.
|
|
zh = thr["zip_half_x"]
|
|
v = np.where(front & (ax < zh), PLACKET_LUMA, v)
|
|
v = np.where(front & (np.abs(ax - zh) < SEAM_W * 0.6), SEAM_LUMA, v)
|
|
teeth = front & (ax < TEETH_HALF_W)
|
|
dash = np.mod(z, TEETH_PERIOD) < TEETH_DUTY
|
|
v = np.where(teeth & dash, TEETH_LUMA, v)
|
|
v = np.where(teeth & ~dash, TEETH_GAP_LUMA, v)
|
|
return v
|
|
|
|
|
|
def _raster_tri_painted(lum, uv_a, uv_b, uv_c, p_a, p_b, p_c, thr, z_min, W, H):
|
|
"""Barycentric fill of a UV triangle, painting per-texel from the
|
|
barycentrically interpolated body-local position."""
|
|
ax_, ay_ = uv_a.x * (W - 1), uv_a.y * (H - 1)
|
|
bx_, by_ = uv_b.x * (W - 1), uv_b.y * (H - 1)
|
|
cx_, cy_ = uv_c.x * (W - 1), uv_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
|
|
pos_x = w0 * p_a.x + w1 * p_b.x + w2 * p_c.x
|
|
pos_y = w0 * p_a.y + w1 * p_b.y + w2 * p_c.y
|
|
pos_z = w0 * p_a.z + w1 * p_b.z + w2 * p_c.z
|
|
vals = _paint_luma(pos_x, pos_y, pos_z, thr, z_min)
|
|
region = lum[miny:maxy + 1, minx:maxx + 1]
|
|
region[inside] = vals[inside]
|
|
|
|
|
|
def bake_painted_albedo(shell, thr, out_path, seed=1093):
|
|
"""Bake a per-body albedo: luma-painted detail in this body's UV0 layout."""
|
|
W = H = ALBEDO_SIZE
|
|
lum = np.full((H, W), BASE_LUMA, 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 albedo bake")
|
|
|
|
z_min = min(v.co.z for v in bm.verts)
|
|
for face in bm.faces:
|
|
loops = face.loops[:]
|
|
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
|
pos = [loop.vert.co.copy() for loop in loops]
|
|
for i in range(1, len(uvs) - 1):
|
|
_raster_tri_painted(lum, uvs[0], uvs[i], uvs[i + 1],
|
|
pos[0], pos[i], pos[i + 1], thr, z_min, W, H)
|
|
bm.free()
|
|
|
|
rng = np.random.default_rng(seed)
|
|
lum += (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
|
|
rgb = np.clip(lum[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0)
|
|
rgba = np.concatenate(
|
|
[rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2).astype(np.float32)
|
|
|
|
img = bpy.data.images.new("garment_painted_albedo", W, H, alpha=False)
|
|
img.pixels.foreach_set(rgba.reshape(-1))
|
|
img.update()
|
|
img.filepath_raw = out_path
|
|
img.file_format = 'PNG'
|
|
img.save()
|
|
log(f"baked painted albedo -> {out_path}")
|
|
return img
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-body authoring
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_outerwear(body_dir, out_dir, body, offset):
|
|
base.clear_scene()
|
|
shell, armature = base.build_covered_mesh(body_dir)
|
|
thr = outerwear_thresholds(armature)
|
|
wrist_cut(shell, thr)
|
|
base.offset_outward(shell, offset)
|
|
base.solidify(shell, CLOTH_THICKNESS_M)
|
|
|
|
# Shared sidecar name on purpose: the exporter derives the embedded glTF
|
|
# image name from the filepath basename, and Godot extracts embedded
|
|
# textures as <glb>_<image>.png — so this yields <body>_base_albedo.png
|
|
# on import, matching the tshirt_modern convention (no doubled names).
|
|
albedo_path = os.path.join(out_dir, "base_albedo.png")
|
|
albedo_img = bake_painted_albedo(shell, thr, albedo_path)
|
|
base.assign_fabric_material(shell, albedo_img)
|
|
|
|
base.author_logo_uv(shell, thr) # UV2 before mask bake (mask uses UV0/active)
|
|
base.bake_region_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr)
|
|
|
|
# Honest region tally (the base bake log can't see the A channel).
|
|
counts = {"collar": 0, "body": 0, "sleeve": 0, "zip/trim": 0}
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
for face in bm.faces:
|
|
c = classify_region_outerwear(face.calc_center_median(), thr)
|
|
if c[3] > 0.5:
|
|
counts["zip/trim"] += 1
|
|
elif c[2] > 0.5:
|
|
counts["sleeve"] += 1
|
|
elif c[0] > 0.5:
|
|
counts["collar"] += 1
|
|
else:
|
|
counts["body"] += 1
|
|
bm.free()
|
|
total = max(sum(counts.values()), 1)
|
|
log("outerwear regions: " + " ".join(
|
|
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
|
|
|
|
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] [--cuff-keep F]")
|
|
sys.exit(1)
|
|
bodies_root = argv[0]
|
|
out_dir = argv[1]
|
|
|
|
global CUFF_KEEP_FRAC
|
|
offset = OFFSET_M
|
|
if "--offset" in argv:
|
|
offset = float(argv[argv.index("--offset") + 1])
|
|
if "--cuff-keep" in argv:
|
|
CUFF_KEEP_FRAC = float(argv[argv.index("--cuff-keep") + 1])
|
|
bodies = base.BODY_TYPES
|
|
if "--bodies" in argv:
|
|
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
|
# Author the reference body LAST so the shared base_albedo.png sidecar
|
|
# (rewritten per body before each export) ends as average_m's version.
|
|
if base.REFERENCE_BODY in bodies:
|
|
bodies = [b for b in bodies if b != base.REFERENCE_BODY] + [base.REFERENCE_BODY]
|
|
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
# Route the base mask bake through the 4-region outerwear classifier and
|
|
# the covered-segment import through the full-arm list.
|
|
base._classify_region = classify_region_outerwear
|
|
base.COVERED_SEGMENTS = SEGMENTS
|
|
|
|
log(f"outerwear per-body mode: {len(bodies)} bodies, "
|
|
f"offset {offset*1000:.0f} mm, cuff keep {CUFF_KEEP_FRAC:.2f}")
|
|
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_outerwear(body_dir, out_dir, body, offset)
|
|
results.append((body, "ok"))
|
|
except Exception as exc:
|
|
log(f"ERROR {body}: {exc}")
|
|
results.append((body, f"error: {exc}"))
|
|
|
|
# Runtime fallback: reference_mask.png mirrors average_m's mask.
|
|
# (base_albedo.png already holds average_m's albedo — it authored last.)
|
|
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(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.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()
|