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

525 lines
20 KiB
Python

"""
blender_author_lower_shell.py (T-1089, lower-body offset-shell companion)
Author LOWER-BODY offset-shell garments (the pants family) per body, reusing
blender_author_offset_shell.py as a library (scene build, offset, solidify,
raster helpers, export). The base script stays the torso/t-shirt reference;
this companion parameterizes the lower-body family so pants_formal, jeans,
shorts, joggers etc. are all CLI parameter sets over ONE code path — reusable
parameters over one-off hacks.
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r.
Natural boundaries give the waist opening (top of seg_hips) and ankle hems
(bottom of seg_leg_lower) for free; a leg cut is only applied for --leg-frac
< 1.0 (shorts family).
Region mask (RGBA, UV0-aligned — toon_garment.gdshader convention):
R = waistband (top band of the hips, height derived from the pelvis bone)
G = legs (everything else — the main fabric region)
B = cuffs (optional, --cuff-frac > 0; joggers)
Painted texture identity (baked into the per-body albedo, luma-carried so it
survives any region tint — the shader replaces hue but keeps luminance):
--crease subtle LIGHT front crease line down each leg (formal press line),
drawn by intersecting the shell with each leg's hip->knee->ankle
axis plane and rasterizing the crossing segments in UV space.
--seam subtle DARK horizontal seam line at the waistband boundary.
Per-body mode only (route guidance from Q-060 / 216-capture QA evidence:
offset-shell garments author per body; weights inherited by construction).
Usage:
tooling/blender --background --python \
tooling/garment-fit/blender_author_lower_shell.py -- \
<bodies_root> <out_dir> \
[--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \
[--waistband-frac 1.0] [--cuff-frac 0.0] \
[--fabric R,G,B] [--fabric-noise 0.012] \
[--no-crease] [--crease-gain 0.10] [--no-seam] [--seam-gain 0.05] \
[--seed 1093]
Writes per body:
<out_dir>/<body>.glb skinned garment authored on that body
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0 layout)
Plus:
<out_dir>/base_albedo.png reference body's albedo (sidecar; the
painted albedo is embedded per GLB, and
the Godot importer extracts it per body
as <body>_base_albedo.png)
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe).
"""
import os
import shutil
import sys
import bpy
import bmesh
import numpy as np
# Make the sibling base module importable inside Blender.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import blender_author_offset_shell as base # noqa: E402
# --------------------------------------------------------------------------
# Lower-body garment parameters (CLI-overridable)
# --------------------------------------------------------------------------
LOWER_SEGMENTS = [
"seg_hips",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
DEFAULTS = {
"offset": 0.010, # slim silhouette standoff (m); per-body authoring
# guarantees clearance by construction, and bottoms
# sit UNDER tops (t-shirt hem = 12 mm), so 10 mm
# keeps the layering order correct at the waist.
"leg_frac": 1.0, # fraction of hip->ankle length kept (1.0 = full)
"waistband_frac": 1.0, # waistband starts at pelvis head + frac*bone_len
"cuff_frac": 0.0, # B-region cuff height as fraction of knee->ankle
"fabric": (0.145, 0.147, 0.160), # dark charcoal, slight cool cast
"fabric_noise": 0.012, # +/- albedo jitter, subtle woven feel
"crease": True, # painted front crease line per leg
"crease_gain": 0.10, # luma lift along the crease (subtle press line)
"seam": True, # painted seam at the waistband boundary
"seam_gain": 0.05, # luma drop along the seam
"seed": 1093, # deterministic albedo noise
}
CREASE_TOP_MARGIN = 0.12 # crease starts this fraction below the hip joint
CREASE_NORMAL_MIN = 0.35 # face must point forward this much for the crease
LINE_RADIUS_PX = 0.9 # painted line half-width in texels
def log(msg):
print(f"[lower-shell] {msg}")
# --------------------------------------------------------------------------
# Threshold derivation (bone landmarks; the 11 bodies share the 65-bone rig)
# --------------------------------------------------------------------------
def derive_lower_thresholds(armature, p):
"""Derive waistband/cuff z-planes and per-leg axis polylines.
Anchors: pelvis (waistband band), thigh_* head (hip joint), thigh_* tail /
calf_* head (knee), calf_* tail (ankle). All in body-local metres so each
body derives its OWN proportionally-consistent parameters.
"""
bones = armature.data.bones
pelvis = bones.get("pelvis")
need = ["thigh_l", "thigh_r", "calf_l", "calf_r"]
legs = {}
for side in ("l", "r"):
thigh = bones.get(f"thigh_{side}")
calf = bones.get(f"calf_{side}")
if thigh is None or calf is None:
raise RuntimeError(f"landmark bones missing for leg _{side} ({need})")
# Polyline hip -> knee -> ankle in the XZ plane (rest pose, z-up).
legs[side] = {
"hip": (thigh.head_local.x, thigh.head_local.z),
"knee": (thigh.tail_local.x, thigh.tail_local.z),
"ankle": (calf.tail_local.x, calf.tail_local.z),
}
if pelvis is None:
raise RuntimeError("pelvis bone missing — cannot derive waistband")
pelvis_len = pelvis.tail_local.z - pelvis.head_local.z
wb_z = pelvis.head_local.z + p["waistband_frac"] * pelvis_len
hip_z = (legs["l"]["hip"][1] + legs["r"]["hip"][1]) / 2.0
ankle_z = (legs["l"]["ankle"][1] + legs["r"]["ankle"][1]) / 2.0
knee_z = (legs["l"]["knee"][1] + legs["r"]["knee"][1]) / 2.0
leg_len = hip_z - ankle_z
cut_z = None
if p["leg_frac"] < 0.999:
cut_z = hip_z - p["leg_frac"] * leg_len
cuff_z = None
if p["cuff_frac"] > 1e-6:
cuff_z = ankle_z + p["cuff_frac"] * (knee_z - ankle_z)
thr = {
"wb_z": wb_z,
"cut_z": cut_z,
"cuff_z": cuff_z,
"legs": legs,
"hip_z": hip_z,
"ankle_z": ankle_z,
"leg_len": leg_len,
}
log(f"thresholds: waistband z>={wb_z:.3f} "
f"cut_z={cut_z if cut_z is None else round(cut_z, 3)} "
f"cuff_z={cuff_z if cuff_z is None else round(cuff_z, 3)} "
f"hip_z={hip_z:.3f} ankle_z={ankle_z:.3f}")
return thr
def _leg_axis_x(leg, z):
"""Piecewise-linear x of the leg axis at height z (clamped to endpoints)."""
hx, hz = leg["hip"]
kx, kz = leg["knee"]
ax, az = leg["ankle"]
if z >= hz:
return hx
if z <= az:
return ax
if z >= kz:
t = (hz - z) / max(hz - kz, 1e-6)
return hx + t * (kx - hx)
t = (kz - z) / max(kz - az, 1e-6)
return kx + t * (ax - kx)
# --------------------------------------------------------------------------
# Seam weld (pre-offset)
# --------------------------------------------------------------------------
def weld_seams(shell):
"""Weld coincident verts before offsetting.
glTF import splits vertices along UV seams (and the joined segment
boundaries duplicate their shared rings), so offsetting each copy along
its OWN split normal tears the shell open — on the pants this showed as a
skin-coloured slit down the front-centre seam of the hips. Welding fuses
the copies so the offset moves one vertex along one averaged normal.
Per-loop UVs are untouched (the UV seam itself survives); coincident
copies carry identical bone weights by construction, so skinning is
unaffected.
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=1e-4)
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"welded seams: {before} -> {len(shell.data.vertices)} verts")
# --------------------------------------------------------------------------
# Leg cut (shorts family; skipped at leg_frac 1.0)
# --------------------------------------------------------------------------
def leg_cut(shell, cut_z):
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
doomed = [v for v in bm.verts if v.co.z < cut_z]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"leg cut at z={cut_z:.3f}: removed {len(doomed)} verts; "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# Painted albedo (charcoal base + crease/seam lines, luma-carried identity)
# --------------------------------------------------------------------------
def _plane_crossings_uv(face, uv_layer, dist_fn):
"""UV points where the face's edge loop crosses dist_fn(co) == 0."""
loops = face.loops[:]
n = len(loops)
pts = []
for i in range(n):
la, lb = loops[i], loops[(i + 1) % n]
d1 = dist_fn(la.vert.co)
d2 = dist_fn(lb.vert.co)
if (d1 > 0.0) == (d2 > 0.0):
continue
denom = d1 - d2
if abs(denom) < 1e-9:
continue
t = d1 / denom
uv1 = la[uv_layer].uv
uv2 = lb[uv_layer].uv
pts.append((uv1[0] + t * (uv2[0] - uv1[0]),
uv1[1] + t * (uv2[1] - uv1[1])))
return pts
def _stamp_uv_line(hits, a, b, W, H):
"""Mark texels along UV segment a->b into boolean mask `hits`."""
ax, ay = a[0] * (W - 1), a[1] * (H - 1)
bx, by = b[0] * (W - 1), b[1] * (H - 1)
steps = int(max(abs(bx - ax), abs(by - ay)) * 2.0) + 1
r = LINE_RADIUS_PX
for i in range(steps + 1):
t = i / steps
px = ax + (bx - ax) * t
py = ay + (by - ay) * t
x0 = max(int(np.floor(px - r)), 0)
x1 = min(int(np.ceil(px + r)), W - 1)
y0 = max(int(np.floor(py - r)), 0)
y1 = min(int(np.ceil(py + r)), H - 1)
for yy in range(y0, y1 + 1):
for xx in range(x0, x1 + 1):
if (xx - px) ** 2 + (yy - py) ** 2 <= r * r + 0.25:
hits[yy, xx] = True
def bake_painted_albedo(shell, thr, p):
"""Charcoal fabric + painted crease/seam lines, baked in UV0 space.
The toon_garment shader keeps only albedo LUMINANCE under region tints, so
identity painted here (light crease, dark seam) survives any recolor.
"""
W = H = base.ALBEDO_SIZE
rng = np.random.default_rng(p["seed"])
fabric = np.array(p["fabric"], dtype=np.float32)
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * p["fabric_noise"]
rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
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 albedo bake")
crease_hits = np.zeros((H, W), dtype=bool)
seam_hits = np.zeros((H, W), dtype=bool)
crease_faces = 0
seam_faces = 0
crease_top = thr["hip_z"] - CREASE_TOP_MARGIN * thr["leg_len"]
for face in bm.faces:
center = face.calc_center_median()
# Waistband seam: any face crossing the wb_z plane (full ring).
if p["seam"]:
pts = _plane_crossings_uv(face, uv_layer,
lambda co: co.z - thr["wb_z"])
if len(pts) >= 2:
_stamp_uv_line(seam_hits, pts[0], pts[1], W, H)
seam_faces += 1
# Front crease: forward-facing faces crossing the leg-axis plane.
if p["crease"]:
if face.normal.y * base.FRONT_Y_SIGN < CREASE_NORMAL_MIN:
continue
if center.z > crease_top or center.z < thr["ankle_z"]:
continue
leg = thr["legs"]["l"] if center.x >= 0.0 else thr["legs"]["r"]
pts = _plane_crossings_uv(
face, uv_layer, lambda co: co.x - _leg_axis_x(leg, co.z))
if len(pts) >= 2:
_stamp_uv_line(crease_hits, pts[0], pts[1], W, H)
crease_faces += 1
bm.free()
if p["seam"]:
rgb[seam_hits, :] = np.clip(rgb[seam_hits, :] - p["seam_gain"], 0.0, 1.0)
if p["crease"]:
rgb[crease_hits, :] = np.clip(rgb[crease_hits, :] + p["crease_gain"], 0.0, 1.0)
log(f"painted albedo: crease faces={crease_faces} "
f"({int(crease_hits.sum())} px) seam faces={seam_faces} "
f"({int(seam_hits.sum())} px)")
if p["crease"] and crease_faces == 0:
log("WARNING: crease painted on 0 faces — check leg axis / front sign")
rgba = np.concatenate(
[rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
img = bpy.data.images.new("garment_lower_albedo", W, H, alpha=False)
img.pixels.foreach_set(rgba.reshape(-1))
img.update()
return img
# --------------------------------------------------------------------------
# Region mask (waistband R / legs G / optional cuff B)
# --------------------------------------------------------------------------
def _classify_lower(center, thr):
if center.z >= thr["wb_z"]:
return (1.0, 0.0, 0.0, 0.0) # waistband -> R (tint_0)
if thr["cuff_z"] is not None and center.z <= thr["cuff_z"]:
return (0.0, 0.0, 1.0, 0.0) # cuff -> B (tint_2)
return (0.0, 1.0, 0.0, 0.0) # legs -> G (tint_1)
def bake_lower_mask(shell, out_path, thr):
W = H = base.MASK_SIZE
buf = np.zeros((H, W, 4), dtype=np.float32)
buf[:, :, 1] = 1.0 # green background = legs (main region)
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 region mask bake")
counts = {"waistband": 0, "legs": 0, "cuff": 0}
for face in bm.faces:
color = _classify_lower(face.calc_center_median(), thr)
if color[0] > 0.5:
counts["waistband"] += 1
elif color[2] > 0.5:
counts["cuff"] += 1
else:
counts["legs"] += 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()))
if counts["waistband"] == 0:
log("WARNING: waistband region empty — check waistband_frac / pelvis")
img = bpy.data.images.new("garment_lower_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}")
# --------------------------------------------------------------------------
# Parked logo UV2 (pants are not logo-capable; keep the channel guarded)
# --------------------------------------------------------------------------
def author_parked_logo_uv(shell):
me = shell.data
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
logo_uv = me.uv_layers.new(name="logo_uv")
me.uv_layers.active = me.uv_layers[0]
data = logo_uv.data
parked = [2.0, 2.0] * len(data)
logo_uv.data.foreach_set("uv", parked)
me.update()
log(f"logo UV2 parked outside [0,1] on all {len(data)} loops")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_lower_shell(body_dir, out_dir, body, p):
base.clear_scene()
base.COVERED_SEGMENTS = LOWER_SEGMENTS # reuse the base segment importer
shell, armature = base.build_covered_mesh(body_dir)
thr = derive_lower_thresholds(armature, p)
zs = [v.co.z for v in shell.data.vertices]
log(f"shell z-range: {min(zs):.3f}..{max(zs):.3f}")
if thr["wb_z"] >= max(zs):
log("WARNING: waistband plane above shell top — band will be empty")
weld_seams(shell)
if thr["cut_z"] is not None:
leg_cut(shell, thr["cut_z"])
base.offset_outward(shell, p["offset"])
base.solidify(shell, base.CLOTH_THICKNESS_M)
albedo_img = bake_painted_albedo(shell, thr, p)
base.assign_fabric_material(shell, albedo_img)
author_parked_logo_uv(shell)
bake_lower_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr)
# Save under the SHARED sidecar name for every body: the glTF exporter
# names the embedded image after this filepath, and the Godot importer
# extracts embedded textures as <glb>_<imagename>.png — so this yields the
# per-body <body>_base_albedo.png convention (as tshirt_modern) on import.
# The loop authors the reference body LAST so base_albedo.png ends up as
# the reference albedo (per-body albedos differ: painted crease follows
# each body's own geometry).
base.save_albedo_sidecar(
albedo_img, os.path.join(out_dir, "base_albedo.png"))
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def parse_args(argv):
p = dict(DEFAULTS)
# Reference body last: the shared base_albedo.png sidecar is rewritten per
# body, so ordering makes the surviving copy the reference body's.
bodies = [b for b in base.BODY_TYPES if b != base.REFERENCE_BODY]
bodies.append(base.REFERENCE_BODY)
def _val(flag):
return argv[argv.index(flag) + 1]
if "--bodies" in argv:
bodies = [s.strip() for s in _val("--bodies").split(",")]
for flag, key, cast in [
("--offset", "offset", float),
("--leg-frac", "leg_frac", float),
("--waistband-frac", "waistband_frac", float),
("--cuff-frac", "cuff_frac", float),
("--fabric-noise", "fabric_noise", float),
("--crease-gain", "crease_gain", float),
("--seam-gain", "seam_gain", float),
("--seed", "seed", int),
]:
if flag in argv:
p[key] = cast(_val(flag))
if "--fabric" in argv:
p["fabric"] = tuple(float(s) for s in _val("--fabric").split(","))
if "--no-crease" in argv:
p["crease"] = False
if "--no-seam" in argv:
p["seam"] = False
return p, bodies
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print(__doc__)
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
p, bodies = parse_args(argv)
os.makedirs(out_dir, exist_ok=True)
log(f"lower-shell per-body: {len(bodies)} bodies, offset "
f"{p['offset']*1000:.0f} mm, leg_frac {p['leg_frac']}, "
f"fabric {p['fabric']}")
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_lower_shell(body_dir, out_dir, body, p)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
results.append((body, f"error: {exc}"))
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()