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>
374 lines
15 KiB
Python
374 lines
15 KiB
Python
"""
|
||
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()
|