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>
427 lines
17 KiB
Python
427 lines
17 KiB
Python
"""
|
|
blender_author_buttondown.py (T-1089, buttondown_modern — per-body offset shell)
|
|
|
|
Button-down shirt authored per body via the offset-shell route. Imports
|
|
blender_author_offset_shell.py as a module and reuses its scene/build/offset/
|
|
solidify/logo-UV/export helpers; this file adds what the button-down needs
|
|
beyond the t-shirt base:
|
|
|
|
* long-sleeve coverage — torso + torso_upper + BOTH full arms (upper+lower),
|
|
with a wrist-plane cut derived from the lowerarm bone (SLEEVE_END_FRAC),
|
|
replacing the base script's upper-arm short-sleeve cut
|
|
* seam WELD after join — the body segments share exact duplicated boundary
|
|
rings (probe: 24-38 coincident verts/seam, zero near-misses), so a
|
|
remove-doubles pass gives continuous normals across segment seams ->
|
|
crack-free outward offset and no internal solidify rims (UVs live on
|
|
loops, so UV seams survive the weld; weights are identical by construction)
|
|
* per-PIXEL region mask bake (barycentric-interpolated body-local positions)
|
|
instead of the base per-face classification — the placket and cuff
|
|
boundaries cut through the middle of low-poly faces
|
|
* PAINTED albedo — texture-carried identity per the style pin: placket band
|
|
with stitch lines, button dots, collar + cuff seam lines, over the flat
|
|
toon fabric noise. Baked per body because each body archetype has its own
|
|
UV atlas (the shell inherits body UVs).
|
|
|
|
Region convention (spec): collar=R (tint_0), body=G (tint_1),
|
|
cuffs+placket=B (tint_2). A unused. Logo-capable OFF, but the UV2 chest
|
|
channel is still authored (costs nothing; enables future brand variants).
|
|
|
|
Run (per-body only — this garment ships the per-body route):
|
|
|
|
tooling/blender --background --python \
|
|
tooling/garment-fit/blender_author_buttondown.py -- \
|
|
client/assets/characters/bodies \
|
|
client/assets/characters/clothing/buttondown_modern \
|
|
[--bodies average_m,child,...] [--offset 0.009]
|
|
|
|
Writes per body: <out_dir>/<body>.glb + <out_dir>/<body>_mask.png
|
|
Plus: <out_dir>/base_albedo.png (average_m's painted albedo)
|
|
<out_dir>/reference_mask.png (copy of average_m_mask.png)
|
|
|
|
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
|
|
(per-body authoring for offset shells).
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import bmesh
|
|
import bpy
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import blender_author_offset_shell as base # noqa: E402
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Garment parameters (average_m metres; scaled per body by shoulder ratio)
|
|
# --------------------------------------------------------------------------
|
|
|
|
COVERED_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.009 # slimmer standoff than the tee (12 mm) — dress shirt
|
|
CLOTH_THICKNESS_M = 0.003 # thinner cloth than the tee (4 mm)
|
|
SLEEVE_END_FRAC = 0.92 # of lowerarm bone length — long sleeve, ends above wrist
|
|
CUFF_START_FRAC = 0.62 # of lowerarm bone length — last ~38% of forearm = cuff (B)
|
|
WELD_DIST = 0.0002 # merge duplicated segment-boundary rings (0.2 mm)
|
|
|
|
MASK_SIZE = 512
|
|
ALBEDO_SIZE = 1024 # painted detail (buttons ~8 px radius) needs 1024
|
|
|
|
# Style dimensions on average_m (shoulder |x| = base._REF_SHOULDER_X);
|
|
# scaled per body by shoulder_x ratio so proportions hold from child to heavy_m.
|
|
PLACKET_HALF_M = 0.022 # placket half-width (4.4 cm total band)
|
|
STITCH_W_M = 0.0025 # stitch/seam line half-width
|
|
BUTTON_R_M = 0.0095 # button disc radius (chunky toon read)
|
|
SEAM_HALF_M = 0.0022 # collar seam line half-height
|
|
BUTTON_COUNT = 6
|
|
FRONT_MIN_Y = 0.004 # body-local front test: y * FRONT_Y_SIGN > this
|
|
|
|
# Flat toon oxford base tone + painted feature colours (luma carries through
|
|
# the luminance-preserving region tint in toon_garment.gdshader).
|
|
FABRIC_RGB = (0.63, 0.64, 0.66)
|
|
FABRIC_NOISE = 0.02
|
|
PLACKET_BAND_MUL = 1.07 # subtle lift so the band reads under any tint
|
|
STITCH_MUL = 0.52 # dark stitch lines
|
|
SEAM_MUL = 0.55 # collar/cuff seam lines
|
|
BUTTON_RGB_OUTER = (0.10, 0.10, 0.12)
|
|
BUTTON_RGB_CORE = (0.24, 0.24, 0.27)
|
|
|
|
|
|
def log(msg):
|
|
print(f"[buttondown] {msg}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Threshold derivation (extends base.derive_thresholds with arm + style dims)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def derive_arm_thresholds(armature):
|
|
"""Wrist cut planes + cuff start from the lowerarm bones (X-axis arms)."""
|
|
bones = armature.data.bones
|
|
la_l = bones.get("lowerarm_l")
|
|
la_r = bones.get("lowerarm_r")
|
|
if la_l is None or la_r is None:
|
|
raise RuntimeError("lowerarm bones missing — cannot derive sleeve cut")
|
|
|
|
def along(b, frac):
|
|
return b.head_local.x + frac * (b.tail_local.x - b.head_local.x)
|
|
|
|
cut_l = along(la_l, SLEEVE_END_FRAC) # left arm +x: delete x > cut_l
|
|
cut_r = along(la_r, SLEEVE_END_FRAC) # right arm -x: delete x < cut_r
|
|
cuff_x_abs = (abs(along(la_l, CUFF_START_FRAC))
|
|
+ abs(along(la_r, CUFF_START_FRAC))) / 2.0
|
|
log(f"sleeve: cut_l={cut_l:.3f} cut_r={cut_r:.3f} cuff |x|>={cuff_x_abs:.3f}")
|
|
return {"cut_l": cut_l, "cut_r": cut_r, "cuff_x_abs": cuff_x_abs}
|
|
|
|
|
|
def derive_style(armature):
|
|
"""Scale the painted-detail dimensions by this body's shoulder ratio."""
|
|
bones = armature.data.bones
|
|
ua_l = bones.get("upperarm_l")
|
|
ua_r = bones.get("upperarm_r")
|
|
if ua_l is None or ua_r is None:
|
|
s = 1.0
|
|
else:
|
|
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
|
s = shoulder_x / base._REF_SHOULDER_X
|
|
log(f"style scale {s:.3f}")
|
|
return {
|
|
"placket_half": PLACKET_HALF_M * s,
|
|
"stitch_w": STITCH_W_M * s,
|
|
"button_r": BUTTON_R_M * s,
|
|
"seam_half": SEAM_HALF_M * s,
|
|
}
|
|
|
|
|
|
def derive_buttons(shell, thr):
|
|
"""Button Z positions: evenly spaced down the placket (collar -> hem)."""
|
|
hem_z = min(v.co.z for v in shell.data.vertices)
|
|
collar_z = thr["collar_z_min"]
|
|
span = collar_z - hem_z
|
|
z_top = collar_z - 0.05 * span
|
|
z_bot = hem_z + 0.07 * span
|
|
zs = list(np.linspace(z_top, z_bot, BUTTON_COUNT))
|
|
log(f"buttons: {BUTTON_COUNT} @ z {z_bot:.3f}..{z_top:.3f} (hem {hem_z:.3f})")
|
|
return {"button_zs": zs, "hem_z": hem_z}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Geometry: seam weld + wrist cut
|
|
# --------------------------------------------------------------------------
|
|
|
|
def weld_seams(shell):
|
|
"""Merge the duplicated segment-boundary rings into one continuous shell."""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
before = len(bm.verts)
|
|
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST)
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"seam weld: {before} -> {len(shell.data.vertices)} verts "
|
|
f"({before - len(shell.data.vertices)} merged)")
|
|
|
|
|
|
def wrist_cut(shell, thr):
|
|
"""Delete sleeve verts beyond the wrist plane on each forearm."""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
bm.verts.ensure_lookup_table()
|
|
to_delete = [v for v in bm.verts
|
|
if v.co.x > thr["cut_l"] or v.co.x < thr["cut_r"]]
|
|
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")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-pixel bake: region mask + painted albedo in one pass
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _gather_tris(shell):
|
|
"""Fan-triangulate every face into (uv_a, uv_b, uv_c, co_a, co_b, co_c)."""
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.faces.ensure_lookup_table()
|
|
uvl = bm.loops.layers.uv[me.uv_layers[0].name] # UV0 = shared body atlas
|
|
tris = []
|
|
for face in bm.faces:
|
|
loops = face.loops[:]
|
|
uvs = [np.array((lp[uvl].uv.x, lp[uvl].uv.y)) for lp in loops]
|
|
cos = [np.array(lp.vert.co[:]) for lp in loops]
|
|
for i in range(1, len(loops) - 1):
|
|
tris.append((uvs[0], uvs[i], uvs[i + 1],
|
|
cos[0], cos[i], cos[i + 1]))
|
|
bm.free()
|
|
return tris
|
|
|
|
|
|
def _tri_cover(a, b, c, W, H):
|
|
"""Pixel centres covered by UV triangle abc -> (ys, xs, w0, w1, w2)."""
|
|
ax, ay = a[0] * (W - 1), a[1] * (H - 1)
|
|
bx, by = b[0] * (W - 1), b[1] * (H - 1)
|
|
cx, cy = c[0] * (W - 1), c[1] * (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 None
|
|
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
|
if abs(denom) < 1e-9:
|
|
return None # degenerate (solidify rim) — no UV area to write
|
|
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 None
|
|
return (ys[inside].ravel(), xs[inside].ravel(),
|
|
w0[inside].ravel(), w1[inside].ravel(), w2[inside].ravel())
|
|
|
|
|
|
def _interp_pos(cover, co_a, co_b, co_c):
|
|
_, _, w0, w1, w2 = cover
|
|
return (w0[:, None] * co_a[None, :]
|
|
+ w1[:, None] * co_b[None, :]
|
|
+ w2[:, None] * co_c[None, :])
|
|
|
|
|
|
def _classify_px(pos, thr):
|
|
"""Vectorized region classification -> (N,4) one-hot RGBA rows."""
|
|
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
|
ax = np.abs(x)
|
|
front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y
|
|
cuff = ax >= thr["cuff_x_abs"]
|
|
collar = (~cuff) & (z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"])
|
|
placket = ((~cuff) & (~collar) & front
|
|
& (ax <= thr["placket_half"]) & (z < thr["collar_z_min"]))
|
|
rgba = np.zeros((len(x), 4), dtype=np.float32)
|
|
rgba[:, 1] = 1.0 # default: body -> G
|
|
rgba[collar] = (1.0, 0.0, 0.0, 0.0) # collar band -> R
|
|
rgba[cuff | placket] = (0.0, 0.0, 1.0, 0.0) # cuffs + placket -> B
|
|
return rgba
|
|
|
|
|
|
def _paint_px(pos, rows, thr):
|
|
"""Painted albedo: placket band + stitches, seams, button dots (in-place)."""
|
|
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
|
ax = np.abs(x)
|
|
front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y
|
|
below_collar = z < thr["collar_z_min"]
|
|
|
|
mul = np.ones(len(x), dtype=np.float32)
|
|
band = front & below_collar & (ax <= thr["placket_half"])
|
|
mul[band] = PLACKET_BAND_MUL
|
|
stitch = front & below_collar & (np.abs(ax - thr["placket_half"])
|
|
<= thr["stitch_w"])
|
|
mul[stitch] = STITCH_MUL
|
|
collar_seam = ((np.abs(z - thr["collar_z_min"]) <= thr["seam_half"])
|
|
& (ax < thr["collar_x_abs"] * 1.8))
|
|
mul[collar_seam] = SEAM_MUL
|
|
cuff_seam = np.abs(ax - thr["cuff_x_abs"]) <= thr["stitch_w"]
|
|
mul[cuff_seam] = SEAM_MUL
|
|
out = rows * mul[:, None]
|
|
|
|
r = thr["button_r"]
|
|
for bz in thr["button_zs"]:
|
|
d2 = x * x + (z - bz) ** 2
|
|
disc = front & (d2 <= r * r)
|
|
out[disc] = BUTTON_RGB_OUTER
|
|
core = front & (d2 <= (0.45 * r) ** 2)
|
|
out[core] = BUTTON_RGB_CORE
|
|
np.clip(out, 0.0, 1.0, out)
|
|
return out
|
|
|
|
|
|
def bake_maps(shell, thr, mask_path):
|
|
"""One pass over the shell: bake <body>_mask.png + return painted albedo."""
|
|
tris = _gather_tris(shell)
|
|
|
|
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
|
|
mbuf[:, :, 1] = 1.0 # green background = main body (bilinear-bleed safe)
|
|
|
|
rng = np.random.default_rng(1090) # deterministic fabric noise
|
|
fabric = np.array(FABRIC_RGB, dtype=np.float32)
|
|
noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
|
|
- 0.5) * 2.0 * FABRIC_NOISE
|
|
abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
|
|
|
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
|
|
cover = _tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
|
|
if cover is not None:
|
|
pos = _interp_pos(cover, co_a, co_b, co_c)
|
|
mbuf[cover[0], cover[1], :] = _classify_px(pos, thr)
|
|
cover = _tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
|
|
if cover is not None:
|
|
pos = _interp_pos(cover, co_a, co_b, co_c)
|
|
abuf[cover[0], cover[1], :] = _paint_px(
|
|
pos, abuf[cover[0], cover[1], :], thr)
|
|
|
|
tot = MASK_SIZE * MASK_SIZE
|
|
log("mask texels: collar={:.1f}% body={:.1f}% cuff+placket={:.1f}%".format(
|
|
100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
|
|
100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
|
|
100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
|
|
|
|
mask_img = bpy.data.images.new("garment_region_mask", MASK_SIZE, MASK_SIZE,
|
|
alpha=True)
|
|
mask_img.pixels.foreach_set(mbuf.reshape(-1))
|
|
mask_img.update()
|
|
mask_img.filepath_raw = mask_path
|
|
mask_img.file_format = 'PNG'
|
|
mask_img.save()
|
|
log(f"baked region mask -> {mask_path}")
|
|
|
|
argba = np.concatenate(
|
|
[abuf, np.ones((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)],
|
|
axis=2)
|
|
albedo_img = bpy.data.images.new("base_albedo", ALBEDO_SIZE, ALBEDO_SIZE,
|
|
alpha=False)
|
|
albedo_img.pixels.foreach_set(argba.reshape(-1))
|
|
albedo_img.update()
|
|
return albedo_img
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Author one body
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_buttondown(body_dir, out_dir, body, offset):
|
|
base.clear_scene()
|
|
base.COVERED_SEGMENTS = COVERED_SEGMENTS # long-sleeve coverage set
|
|
shell, armature = base.build_covered_mesh(body_dir)
|
|
|
|
thr = base.derive_thresholds(armature) # collar/chest from bone landmarks
|
|
thr.update(derive_arm_thresholds(armature))
|
|
thr.update(derive_style(armature))
|
|
|
|
weld_seams(shell)
|
|
wrist_cut(shell, thr)
|
|
thr.update(derive_buttons(shell, thr))
|
|
|
|
base.offset_outward(shell, offset)
|
|
base.solidify(shell, CLOTH_THICKNESS_M)
|
|
|
|
base.author_logo_uv(shell, thr) # UV2 before bake (mask/albedo use UV0)
|
|
albedo_img = bake_maps(shell, thr, os.path.join(out_dir, f"{body}_mask.png"))
|
|
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
|
base.assign_fabric_material(shell, albedo_img)
|
|
|
|
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Entry
|
|
# --------------------------------------------------------------------------
|
|
|
|
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)
|
|
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.1f} mm")
|
|
|
|
avg_albedo_stash = os.path.join(out_dir, "_tmp_avg_base_albedo.png")
|
|
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_buttondown(body_dir, out_dir, body, offset)
|
|
if body == base.REFERENCE_BODY:
|
|
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
|
avg_albedo_stash)
|
|
results.append((body, "ok"))
|
|
except Exception as exc: # noqa: BLE001 — per-body isolation
|
|
log(f"ERROR {body}: {exc}")
|
|
results.append((body, f"error: {exc}"))
|
|
|
|
# Root sidecars: reference mask + albedo mirror the reference body.
|
|
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")
|
|
if os.path.isfile(avg_albedo_stash):
|
|
shutil.move(avg_albedo_stash, os.path.join(out_dir, "base_albedo.png"))
|
|
log(f"base_albedo.png = {base.REFERENCE_BODY}'s painted albedo")
|
|
|
|
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()
|