Files
settled-reach/tooling/scripts/blender/blender_author_offset_shell_legs.py
T
jpmschweitzerandClaude Opus 5.5 26cc8de7f3 refactor(tooling): T-1290 — the character domain, and six payloads the map misfiled
`reach character {logo, strip-glb, qa, qa-analyze}` replaces make_logo.py,
glb_strip_utility_nodes.py, analyze_captures.py and the run-garment-qa bash
driver. The QA configs and method doc move beside the domain (qa_configs/,
GARMENT_QA.md), and `qa` takes a config name (`reach character qa hoodie_modern`)
or a path.

Parity, from baselines taken before anything moved:

- the logo PNG is byte-identical
- a synthetic GLB with three real utility nodes strips to identical bytes
  (the committed bodies strip 0 nodes, so they proved nothing)
- re-analyzing a cached capture set gives a byte-identical report.json and
  summary

run-garment-qa is rewritten, not wrapped (D-263). Its decisions — which config,
which Godot ($GODOT, then ~/bin/godot4, then PATH), and whether xvfb-run is
needed — are capture_plan(), pinned by tooling/test_character.py without
launching Godot. The bash exit codes are kept: 2 for a missing config, 3 for
no Godot.

The T-1271 domain map was wrong about this domain. Six of its ten files import
bpy: convert_outfit, inspect_glb, check_hair_symmetry, check_icosphere,
render_quaternius_test and test_quaternius_raw. They are Blender payloads and
joined the carve-out as blender_* (41 payloads now). The 22 existing payloads'
docstrings still cited tooling/garment-fit/ from before T-1273; fixed.

Archived, with reasons in tooling/archive/README.md:

- setup_clothing_metadata.py wrote coverage data for five garments that no
  longer exist in the 24-garment wardrobe
- wipe-bodies.sh ran raw DELETEs on systems.db

segment_reference_distribution.md moved to docs/assets/visual/.

Behaviour changes:

- The QA analyzer exited 0 whatever it found, though its own README says
  clip-through "is the real defect and it gates". qa and qa-analyze now exit 1
  on clip-through, and the remedy names --min-pixels (Wave 1/2 were accepted
  at 150). The cached peasant set has 33 failures at the default 8 px.
- glb strip re-reported the same nodes as stripped on every re-run and
  rewrote an unchanged file: it left them as orphans and then found them
  again. Only nodes still linked into the graph count now, and a first pass
  writes the same bytes as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:55:46 +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/scripts/blender/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/scripts/blender/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()