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

374 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
blender_author_offset_shell_legs.py (T-1089, lower-body offset-shell garments)
Companion to blender_author_offset_shell.py (imported as a module — scene
helpers, offset/solidify, albedo, rasterizer and export are reused, not
copied). Where the base script is calibrated for TORSO garments (sleeve cut,
collar/sleeve regions, chest logo UV), this one authors LOWER-BODY garments
from seg_hips + leg segments with parameterized cuts/regions, so one script
serves shorts, jeans, joggers, formal pants, swim trunks:
--coverage thigh|full thigh: seg_hips + seg_leg_upper_l/r (shorts)
full: + seg_leg_lower_l/r (jeans/joggers/formal)
--hem-frac F fraction of the hem bone's length KEPT below its
head (bone-plane cut). Hem bone = thigh for
--coverage thigh, calf for full. 0.78 on the thigh
= casual shorts hem ~9 cm above the knee.
--waistband-frac F waistband band height as a fraction of
(waist rim z thigh head z). The band is
classified R in the region mask; everything else
is G. (B/A unused — 2-region garment.)
--fabric R,G,B flat toon-friendly base tone (luma ~0.6 keeps the
toon_garment.gdshader luma-recolor faithful).
--seed N albedo noise seed (deterministic output).
Cut/region thresholds derive PER BODY from that body's own landmarks, same
philosophy as the base script: the hem plane comes from the thigh/calf bone
(head→tail, identical 65-bone rig on all 11 bodies), the waist rim is the
natural top boundary of seg_hips (its own mesh z-max), so proportions match
across bodies. The waistline itself comes free as a segment boundary — no
waist join geometry needed.
Regions (RGBA mask, toon_garment.gdshader): waistband → R (tint_0),
body → G (tint_1). A parked logo_uv TEXCOORD_1 layer is authored for channel
consistency with torso garments (all UVs outside [0,1] → shader draws nothing).
Modes mirror the base script:
PER-BODY (preferred for offset shells, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
<bodies_dir> <out_dir> --per-body \
[--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35]
Writes <out_dir>/<body>.glb + <out_dir>/<body>_mask.png per body, plus
shared base_albedo.png and reference_mask.png (= average_m's mask, runtime
fallback for the compositor).
SINGLE-REFERENCE:
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
<bodies_dir>/average_m <out_dir> [--offset 0.020] [...]
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
"""
import sys
import os
import shutil
import importlib.util
import bpy # noqa: F401 (Blender runtime)
import bmesh
import numpy as np
# --------------------------------------------------------------------------
# Import the base authoring module (shared helpers; main() is __main__-guarded)
# --------------------------------------------------------------------------
_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
# --------------------------------------------------------------------------
# Parameters (defaults = shorts_modern)
# --------------------------------------------------------------------------
COVERAGE_SEGMENTS = {
"thigh": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"],
"full": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r"],
}
HEM_BONES = {"thigh": ("thigh_l", "thigh_r"), "full": ("calf_l", "calf_r")}
HEM_FRAC = 0.78 # keep 78% of the thigh → hem above the knee
WAISTBAND_FRAC = 0.35 # top 35% of waist-rim→thigh-head span = waistband (R)
FABRIC_RGB = (0.63, 0.60, 0.53) # warm stone khaki, luma ~0.60 (toon-friendly)
ALBEDO_SEED = 20890 # deterministic albedo noise, distinct from the tshirt
# --------------------------------------------------------------------------
# Per-body threshold derivation
# --------------------------------------------------------------------------
def derive_leg_thresholds(armature, coverage, hem_frac):
"""Hem plane from this body's own hem bone; thigh head z for the waistband
span. All 11 bodies share the 65-bone rig, so the landmarks always exist."""
bones = armature.data.bones
hem_l, hem_r = HEM_BONES[coverage]
zs = []
thigh_heads = []
for name in (hem_l, hem_r):
b = bones.get(name)
if b is None:
raise RuntimeError(f"hem bone {name} missing on armature")
zs.append(b.head_local.z + hem_frac * (b.tail_local.z - b.head_local.z))
for name in ("thigh_l", "thigh_r"):
b = bones.get(name)
if b is None:
raise RuntimeError(f"landmark bone {name} missing on armature")
thigh_heads.append(b.head_local.z)
thr = {
"hem_z": sum(zs) / len(zs),
"thigh_head_z": sum(thigh_heads) / len(thigh_heads),
}
log(f"thresholds: hem z>={thr['hem_z']:.3f} "
f"(hem bone {hem_l}/{hem_r}, keep {hem_frac:.2f}) "
f"thigh head z={thr['thigh_head_z']:.3f}")
return thr
# --------------------------------------------------------------------------
# Seam weld — the body segment meshes are assembled from patches whose seam
# vertices are coincident but DUPLICATED. Each copy's normal averages only its
# own patch's faces, so the outward offset pulls seams apart (visible slit at
# the front-centre of seg_hips, V-notches on the waist rim). Welding before
# the offset merges the copies (weights/UVs are identical on coincident verts)
# and gives one smooth normal per seam vertex.
# --------------------------------------------------------------------------
def weld_seams(shell, epsilon=1e-4):
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=epsilon)
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
after = len(shell.data.vertices)
log(f"seam weld merged {before - after} duplicate verts; {after} remain")
# --------------------------------------------------------------------------
# Bone-plane hem cut (z threshold — legs hang along -Z in rest pose)
# --------------------------------------------------------------------------
def hem_cut(shell, hem_z):
"""Delete every vert below the hem plane. A single global z threshold is
safe: the hips segment bottoms out far above any sensible hem."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = [v for v in bm.verts if v.co.z < hem_z]
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"hem cut removed {len(to_delete)} verts below z={hem_z:.3f}; "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# Region mask: waistband (R) / body (G)
# --------------------------------------------------------------------------
def bake_region_mask_legs(shell, out_path, thr, waistband_frac):
"""Rasterize UV0 faces: waistband → R, everything else → G.
MUST run PRE-solidify (open boundaries still present). The waistband is
boundary-anchored: the waist rim is the top open boundary loop of seg_hips
(the rim DIPS ~5 cm at the navel, so a global z-max test misses the front
row), so a face is waistband when it touches a rim-boundary vertex — that
keys the full top face row on every tessellation — or when its centre lies
inside the proportional band. Solidify afterwards duplicates the UV loops
unchanged, so the baked texels serve outer shell, inner shell and rim caps
alike."""
W = H = base.MASK_SIZE
buf = np.zeros((H, W, 4), dtype=np.float32)
buf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
bm.verts.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")
waist_rim_z = max(v.co.z for v in bm.verts)
band = waistband_frac * (waist_rim_z - thr["thigh_head_z"])
wb_z_min = waist_rim_z - band
# Waist-rim boundary verts: on an open boundary AND above the thigh head
# (the only other boundaries are the leg hems, far below).
rim_verts = set()
for e in bm.edges:
if e.is_boundary:
for v in e.verts:
if v.co.z >= thr["thigh_head_z"]:
rim_verts.add(v.index)
log(f"waistband: rim z={waist_rim_z:.3f} band {band*100:.1f} cm "
f"→ R for z>={wb_z_min:.3f} or touching {len(rim_verts)} rim verts")
counts = {"waistband": 0, "body": 0}
for face in bm.faces:
in_band = face.calc_center_median().z >= wb_z_min
touches_rim = any(v.index in rim_verts for v in face.verts)
if in_band or touches_rim:
color = (1.0, 0.0, 0.0, 0.0)
counts["waistband"] += 1
else:
color = (0.0, 1.0, 0.0, 0.0)
counts["body"] += 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()))
img = bpy.data.images.new("garment_region_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}")
def park_logo_uv(shell):
"""Author a TEXCOORD_1 layer with every loop parked outside [0,1] — keeps
the UV-channel layout identical to torso garments; the shader's in-box
guard means nothing ever draws."""
me = shell.data
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
me.uv_layers.new(name="logo_uv")
me.uv_layers.active = me.uv_layers[0]
bm = bmesh.new()
bm.from_mesh(me)
uvl = bm.loops.layers.uv.get("logo_uv")
for face in bm.faces:
for loop in face.loops:
loop[uvl].uv = (2.0, 2.0)
bm.to_mesh(me)
bm.free()
me.update()
log("logo UV2 parked (no logo region on this garment)")
# --------------------------------------------------------------------------
# Author one body
# --------------------------------------------------------------------------
def author_legs_shell(body_dir, out_dir, glb_name, mask_name, offset,
coverage, hem_frac, waistband_frac, seed):
base.clear_scene()
base.COVERED_SEGMENTS = COVERAGE_SEGMENTS[coverage]
shell, armature = base.build_covered_mesh(body_dir)
weld_seams(shell)
thr = derive_leg_thresholds(armature, coverage, hem_frac)
hem_cut(shell, thr["hem_z"])
base.offset_outward(shell, offset)
# Region mask + parked logo UV are authored PRE-solidify: the boundary-
# anchored waistband needs the open waist rim, and solidify duplicates all
# UV loops unchanged so the baked texels stay valid for the final mesh.
park_logo_uv(shell)
bake_region_mask_legs(shell, os.path.join(out_dir, mask_name), thr,
waistband_frac)
base.solidify(shell, base.CLOTH_THICKNESS_M)
base.FABRIC_RGB = FABRIC_RGB
albedo_img = base.make_base_albedo_image(seed=seed)
base.assign_fabric_material(shell, albedo_img)
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
base.export_reference(shell, armature, os.path.join(out_dir, glb_name))
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print("Usage: -- <bodies_dir>/average_m <out_dir> [--offset M] "
"[--coverage thigh|full] [--hem-frac F] [--waistband-frac F] "
"[--fabric R,G,B] [--seed N]\n"
" or: -- <bodies_dir> <out_dir> --per-body [--bodies a,b,c] "
"[same flags]")
sys.exit(1)
in_dir = argv[0]
out_dir = argv[1]
per_body = "--per-body" in argv
global FABRIC_RGB
offset = None
coverage = "thigh"
hem_frac = HEM_FRAC
waistband_frac = WAISTBAND_FRAC
seed = ALBEDO_SEED
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--coverage" in argv:
coverage = argv[argv.index("--coverage") + 1]
if coverage not in COVERAGE_SEGMENTS:
print(f"unknown --coverage {coverage}")
sys.exit(1)
if "--hem-frac" in argv:
hem_frac = float(argv[argv.index("--hem-frac") + 1])
if "--waistband-frac" in argv:
waistband_frac = float(argv[argv.index("--waistband-frac") + 1])
if "--fabric" in argv:
FABRIC_RGB = tuple(
float(c) for c in argv[argv.index("--fabric") + 1].split(","))
if "--seed" in argv:
seed = int(argv[argv.index("--seed") + 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)
if not per_body:
offset = base.OFFSET_M if offset is None else offset
body = os.path.basename(os.path.normpath(in_dir))
author_legs_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png",
offset, coverage, hem_frac, waistband_frac, seed)
log("DONE")
return
offset = base.PER_BODY_OFFSET_M if offset is None else offset
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
f"coverage={coverage} hem_frac={hem_frac} waistband_frac={waistband_frac}")
results = []
for body in bodies:
body_dir = os.path.join(in_dir, body)
log(f"=== {body} ===")
if not os.path.isdir(body_dir):
results.append((body, "skipped: body dir missing"))
continue
try:
author_legs_shell(body_dir, out_dir, f"{body}.glb",
f"{body}_mask.png", offset, coverage, hem_frac,
waistband_frac, seed)
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 (fallback)")
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()