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

503 lines
20 KiB
Python

"""
blender_author_cargo_pants.py (T-1089 wave 2, cargo_pants)
Authors full-length CARGO pants as per-body offset shells. Reuses
blender_author_offset_shell.py as the base library (scene build, join,
offset, solidify, GLB export) and blender_author_denim_pants.py as the
proven bottoms companion (boundary WELD of coincident segment-seam rings,
open-rim FLATTENING onto clean planes, per-body LegLandmarks, waist flare,
hem cut, parked logo UV2) — the denim script's utilities are imported as a
module, not re-derived.
What cargo adds, as reusable parameters:
* --straight: STRAIGHT-FIT boost — extra radial stand-off away from the
per-z leg axis, ramping 0 at the knee to the full value at the ankle, so
the lower leg does not taper with the calf (straight silhouette; also
buys lower-leg clip clearance). Applied post-offset, pre-solidify.
* TEXEL-level cargo feature painting (same one-field-drives-both design as
denim: painted albedo and region mask always agree):
- albedo: large outer-thigh CARGO POCKETS with button-down FLAPS
(filled panels, border stitching, centre pleat, two flap
buttons), outseam/inseam side seams, centre-front fly
stitch, belt loops + waist button, waistband and hem border
stitching — flat tone-on-tone utility look, identity
carried by the texture (style pin: modern only).
- mask: waistband -> R, legs -> G, pockets + flaps -> B
(spec: waistband=R, legs=G, pockets+flaps=B).
A SIGNED azimuth arc around the per-z leg axis (0 at the outer side,
positive toward the front) places the pockets slightly forward-of-side
and the flap buttons symmetrically — the same analytic-field approach as
the denim script, generalised from unsigned to signed arc.
* pocket proportions anchor to each body's own THIGH span (crotch ->
knee) and hip half-width, so the pockets stay proportional across all
11 bodies (child included) — the base.derive_thresholds philosophy.
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r; natural
boundaries give the waist opening and ankle hems. Regions are painted, not
modelled — no 3D pocket geometry (texture carries identity).
Usage (cargo_pants reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_cargo_pants.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/cargo_pants \
[--bodies average_m,child,...] [--offset 0.013] [--hem-frac 1.0] \
[--waist-flare 0.007] [--straight 0.010] [--base-rgb 0.36,0.37,0.29] \
[--plain]
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
<out_dir>/<body>_base_albedo.png
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
<out_dir>/reference_mask.png (average_m's, runtime fallback)
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
wardrobe), Q-060 (per-body offset shells).
"""
import importlib.util
import os
import shutil
import sys
import bmesh
import bpy
import numpy as np
# --------------------------------------------------------------------------
# Import the base offset-shell module + the denim bottoms companion
# (weld / rim-flatten / flare / landmarks utilities live there)
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load(name, fname):
spec = importlib.util.spec_from_file_location(
name, os.path.join(_HERE, fname))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
base = _load("offset_shell_base", "blender_author_offset_shell.py")
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
log = base.log
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
# --------------------------------------------------------------------------
# Parameters
# --------------------------------------------------------------------------
COVERED_SEGMENTS = [
"seg_hips",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
HEM_FRAC = 1.0 # 1.0 = full leg to the ankle
OFFSET_M = 0.013 # cargo sits a touch looser than jeans (0.012)
STRAIGHT_M = 0.010 # straight-fit radial boost at the ankle (0 at knee)
WAIST_FLARE_M = 0.007 # waistband rim stand-off (deep-crouch mitigation,
# proven on jeans)
TEX_SIZE = 1024 # painted pockets/seams need >512
# Vertical proportions — fractions of the garment span (waist_z - hem_z),
# via denim.LegLandmarks.finalize (BAND/CUFF/SEAM fractions proven on jeans).
# Pocket proportions — fractions of the THIGH span (crotch_z - knee_z).
FLAP_TOP_FRAC = 0.18 # flap top below the crotch
FLAP_H_FRAC = 0.14 # flap height
POCKET_BOT_FRAC = 0.74 # pocket bottom below the crotch
# Horizontal proportions — signed azimuth ARC around the per-z leg axis
# (metres on the reference body, scaled by the body's hip ratio lm.sh).
POCKET_HALF_ARC_M = 0.058 # pocket half-width
FLAP_EXTRA_ARC_M = 0.007 # flap overhangs the pocket by this much per side
POCKET_FWD_BIAS_M = 0.010 # pocket centre sits slightly forward of the outseam
BUTTON_R_M = 0.0075 # flap button radius
LOOP_X_FRACS = (0.55, 1.30) # belt-loop |x| positions (fractions of hip_x)
LOOP_W_M = 0.016 # belt loop width (m, scaled by hip ratio)
# Utility olive/grey style (sRGB floats; flat toon-friendly, tone-on-tone).
CARGO_RGB = (0.360, 0.370, 0.290) # utility olive-grey
THREAD_RGB = (0.225, 0.235, 0.185) # tone-on-tone darker stitching
BUTTON_RGB = (0.170, 0.160, 0.140) # matte button
ALBEDO_NOISE = 0.020 # +/- woven jitter
POCKET_SHADE = 0.93 # pocket panel albedo darkening
FLAP_SHADE = 0.85 # flap panel albedo darkening
LOOP_SHADE = 0.80 # belt-loop albedo darkening
PLAIN = False # --plain: skip pockets/stitch/button paint
NOISE_SEED = 3089
# --------------------------------------------------------------------------
# Geometry: straight-fit boost (cargo's reusable fit parameter)
# --------------------------------------------------------------------------
def straight_boost(shell, lm, boost):
"""Extra RADIAL stand-off away from the per-z leg axis below the knee,
ramping linearly from 0 at the knee to `boost` at the ankle. The body
calf tapers; pushing the shell out progressively keeps the pant leg
straight (cargo silhouette) instead of hugging the calf. Applied
post-offset, pre-solidify; weights/UVs are untouched."""
if boost <= 0.0:
return
knee_z = float(lm.leg_z_pts[1])
ankle_z = float(lm.leg_z_pts[0])
span = max(knee_z - ankle_z, 1e-6)
bm = bmesh.new()
bm.from_mesh(shell.data)
n = 0
for v in bm.verts:
z = v.co.z
if z >= knee_z:
continue
t = min((knee_z - z) / span, 1.0)
side = 1.0 if v.co.x >= 0.0 else -1.0
cx = side * float(np.interp(z, lm.leg_z_pts, lm.leg_x_pts))
cy = float(np.interp(z, lm.leg_z_pts, lm.leg_y_pts))
dx = v.co.x - cx
dy = v.co.y - cy
r = (dx * dx + dy * dy) ** 0.5
if r > 1e-6:
v.co.x += boost * t * dx / r
v.co.y += boost * t * dy / r
n += 1
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"straight-fit boost: {n} verts, +{boost * 1000:.1f} mm radial at "
f"ankle (ramped from knee z={knee_z:.3f})")
# --------------------------------------------------------------------------
# Cargo feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _cargo_field(px, py, pz, lm):
"""Evaluate cargo features at texel 3D positions (numpy arrays).
Returns a dict of bool arrays. `pocket`/`flap` feed the mask B channel;
the stitch/fill features feed the albedo only.
"""
w2 = lm.seam_w * 0.5
front = py * FRONT_Y_SIGN > 0.004
in_band = pz >= lm.band_z
mid = (~in_band) & (pz > lm.hem_z + 0.5 * lm.cuff_h)
# Per-z leg axis, mirrored by x sign; SIGNED azimuth arc around it
# (0 at the outer side, positive toward the front, +/-pi*r at the inseam).
side = np.where(px >= 0.0, 1.0, -1.0)
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
dx = px - cx
dy = py - cy
r = np.hypot(dx, dy) + 1e-9
theta = np.arctan2(dy * FRONT_Y_SIGN / r, np.clip(dx * side / r, -1.0, 1.0))
sarc = theta * r # signed arc distance from the outer direction
# Side seams: outseam on the outer azimuth; inseam opposite, below crotch.
outseam = mid & (np.abs(sarc) < w2)
arc_in = (np.pi - np.abs(theta)) * r
inseam = mid & (arc_in < w2) \
& (pz < lm.crotch_z - 0.01 * lm.span / denim._REF_SPAN)
# Centre-front fly stitch (slightly off-centre, like the jeans J-front).
fly = front & mid & (np.abs(px - 0.012 * lm.sh) < w2) \
& (pz > lm.crotch_z + 0.015 * lm.span / denim._REF_SPAN)
# --- cargo pocket + flap (outer thigh, slightly forward-of-side) --------
knee_z = float(lm.leg_z_pts[1])
thigh = max(lm.crotch_z - knee_z, 1e-6)
flap_top = lm.crotch_z - FLAP_TOP_FRAC * thigh
flap_bot = flap_top - FLAP_H_FRAC * thigh
pocket_bot = lm.crotch_z - POCKET_BOT_FRAC * thigh
p_arc = POCKET_HALF_ARC_M * lm.sh
f_arc = p_arc + FLAP_EXTRA_ARC_M * lm.sh
a = sarc - POCKET_FWD_BIAS_M * lm.sh # pocket-centred signed arc
pocket = (np.abs(a) < p_arc) & (pz <= flap_top) & (pz >= pocket_bot)
flap = (np.abs(a) < f_arc) & (pz <= flap_top) & (pz >= flap_bot)
# Pocket border stitching (sides + bottom), centre pleat, flap edge.
p_side = (np.abs(np.abs(a) - p_arc) < w2) & (pz <= flap_top) \
& (pz >= pocket_bot)
p_bottom = (np.abs(pz - pocket_bot) < w2) & (np.abs(a) < p_arc)
pleat = (np.abs(a) < w2) & (pz < flap_bot - 2.0 * w2) & (pz >= pocket_bot)
f_edge = flap & ((np.abs(np.abs(a) - f_arc) < w2)
| (np.abs(pz - flap_bot) < w2))
pkt_stitch = p_side | p_bottom | pleat | f_edge
# Two flap buttons, symmetric about the pocket centre.
btn_r = BUTTON_R_M * lm.sh
btn_z = flap_bot + 0.30 * (flap_top - flap_bot)
buttons = np.zeros_like(front)
for bx in (-0.45 * f_arc, 0.45 * f_arc):
buttons |= np.hypot(a - bx, pz - btn_z) < btn_r
# Border stitching: waistband seam + hem stitch.
wstitch = np.abs(pz - lm.band_z) < w2
hemstitch = np.abs(pz - lm.cuff_top) < w2
return {
"front": front, "in_band": in_band,
"seams": outseam | inseam | fly,
"wstitch": wstitch, "hemstitch": hemstitch,
"pocket": pocket, "flap": flap,
"pkt_stitch": pkt_stitch, "buttons": buttons,
}
def _paint_texels(px, py, pz, noise, lm):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
n = px.shape[0]
f = _cargo_field(px, py, pz, lm)
# --- albedo -------------------------------------------------------------
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = CARGO_RGB[c] + noise
alb[:, 3] = 1.0
if not PLAIN:
# Belt loops: darkened bands on the waistband.
loop_w = LOOP_W_M * lm.sh
loops = np.zeros(n, dtype=bool)
for fx in LOOP_X_FRACS:
loops |= np.abs(np.abs(px) - fx * lm.hip_x) < loop_w * 0.5
loops |= (~f["front"]) & (np.abs(px) < loop_w * 0.5) # centre-back
loops &= f["in_band"]
alb[loops, :3] *= LOOP_SHADE
# Panel fills first, stitch lines on top.
alb[f["pocket"] & ~f["flap"], :3] *= POCKET_SHADE
alb[f["flap"], :3] *= FLAP_SHADE
thread = f["seams"] | f["wstitch"] | f["hemstitch"] | f["pkt_stitch"]
alb[thread, 0] = THREAD_RGB[0]
alb[thread, 1] = THREAD_RGB[1]
alb[thread, 2] = THREAD_RGB[2]
# Flap buttons + waist button.
btn = f["buttons"] | (f["front"] & (
np.hypot(px, pz - (lm.band_z + 0.5 * lm.band_h)) < 0.009 * lm.sh))
alb[btn, 0] = BUTTON_RGB[0]
alb[btn, 1] = BUTTON_RGB[1]
alb[btn, 2] = BUTTON_RGB[2]
# --- region mask: waistband R / legs G / pockets+flaps B -----------------
mask = np.zeros((n, 4), dtype=np.float32)
is_b = (f["pocket"] | f["flap"]) & ~f["in_band"]
is_g = ~(f["in_band"] | is_b)
mask[f["in_band"], 0] = 1.0
mask[is_b, 2] = 1.0
mask[is_g, 1] = 1.0
return alb, mask
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
evaluate the cargo field, write albedo + mask together (same rasterizer
contract as the denim companion, pointed at the cargo painter)."""
a, b, c = uvs
A, B, C = cos
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, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
pxg = xs + 0.5
pyg = ys + 0.5
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
w2 = 1.0 - w0 - w1
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
if not inside.any():
return
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
px3 = w0i * A.x + w1i * B.x + w2i * C.x
py3 = w0i * A.y + w1i * B.y + w2i * C.y
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
ysin = ys[inside]
xsin = xs[inside]
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
alb_buf[ysin, xsin] = alb
mask_buf[ysin, xsin] = mask
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
"""Rasterize all UV0 triangles once, producing the painted albedo and the
region mask from one shared feature-field evaluation per texel."""
W = H = TEX_SIZE
rng = np.random.default_rng(NOISE_SEED)
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
* 2.0 * ALBEDO_NOISE)
alb_buf = np.empty((H, W, 4), dtype=np.float32)
for c in range(3):
alb_buf[:, :, c] = CARGO_RGB[c] + noise_buf
alb_buf[:, :, 3] = 1.0
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
mask_buf[:, :, 1] = 1.0 # background = legs green (bleed-safe)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
if not len(bm.loops.layers.uv):
raise RuntimeError("no UV layer for albedo/mask paint")
uv_layer = bm.loops.layers.uv[0]
tri_count = 0
for face in bm.faces:
loops = face.loops[:]
uvs = [loop[uv_layer].uv.copy() for loop in loops]
cos = [loop.vert.co.copy() for loop in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_paint(
alb_buf, mask_buf, noise_buf,
(uvs[0], uvs[i], uvs[i + 1]),
(cos[0], cos[i], cos[i + 1]),
lm, W, H)
tri_count += 1
bm.free()
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
def _save(buf, name, path):
img = bpy.data.images.new(name, W, H, alpha=True)
img.pixels.foreach_set(buf.reshape(-1))
img.update()
img.filepath_raw = path
img.file_format = 'PNG'
img.save()
return img
albedo_img = _save(alb_buf, f"cargo_albedo_{body}", albedo_path)
_save(mask_buf, f"cargo_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
return albedo_img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_cargo_shell(body_dir, out_dir, body, offset, hem_frac):
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
denim.weld_boundaries(shell)
zs = [v.co.z for v in shell.data.vertices]
waist_z, ankle_z = max(zs), min(zs)
lm = denim.LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
hem_z = denim.hem_cut(shell, lm, hem_frac)
ankle_plane = float(lm.leg_z_pts[0]) if hem_frac >= 0.999 else hem_z
waist_plane, ankle_plane = denim.flatten_open_rims(shell, ankle_plane)
base.offset_outward(shell, offset)
straight_boost(shell, lm, STRAIGHT_M)
denim.waist_flare(shell, waist_plane,
denim.BAND_FRAC * (waist_plane - ankle_plane),
WAIST_FLARE_M)
base.solidify(shell, base.CLOTH_THICKNESS_M)
denim.clamp_waist_residue(shell, waist_plane)
# Landmarks reference the CLEAN rims (band under the flattened waist edge,
# hem stitch above the flattened ankle rim).
lm.waist_z = waist_plane
lm.finalize(ankle_plane)
denim.author_parked_uv2(shell)
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
mask_path = os.path.join(out_dir, f"{body}_mask.png")
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
base.assign_fabric_material(shell, albedo_img)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
def main():
global HEM_FRAC, CARGO_RGB, PLAIN, WAIST_FLARE_M, STRAIGHT_M
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] [--hem-frac F] [--waist-flare M] [--straight M] "
"[--base-rgb r,g,b] [--plain]")
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])
if "--hem-frac" in argv:
HEM_FRAC = float(argv[argv.index("--hem-frac") + 1])
if "--waist-flare" in argv:
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
if "--straight" in argv:
STRAIGHT_M = float(argv[argv.index("--straight") + 1])
if "--base-rgb" in argv:
CARGO_RGB = tuple(
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
if "--plain" in argv:
PLAIN = True
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"cargo per-body mode: {len(bodies)} bodies, offset "
f"{offset * 1000:.0f} mm, straight {STRAIGHT_M * 1000:.0f} mm, "
f"hem-frac {HEM_FRAC}, plain={PLAIN}")
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_cargo_shell(body_dir, out_dir, body, offset, HEM_FRAC)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
ref = base.REFERENCE_BODY
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
if os.path.isfile(ref_mask):
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
if os.path.isfile(ref_alb):
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
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()