Files
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

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()