Files
settled-reach/tooling/scripts/blender/blender_author_lower_shell.py
T
jpmschweitzerandClaude Opus 5.5 c597ec9131 docs(tooling): T-1253 — sweep the live references to retired tool paths
A script scanned every tracked doc, rule, skill, agent, hook and source file
for tooling/ paths that no longer exist, skipping historical records (sprints,
discussions, workshops, governance, generated wiki pages). It found 62. The
ones that tell a reader what to RUN now name the reach verb:

- The atlas skill still sent agents to tooling/atlas, atlas-verify,
  atlas-update-field and atlas-commit-and-sync — about forty lines, all
  retired in T-1285. They now name the `reach atlas` verbs, and the skill
  records that commit-and-sync STAGES by default (--commit to commit) and
  takes --corridor as an option.
- The clerk agent named tooling/clerk-review (now `reach dev clerk`). The Si
  and clerk briefings sent those agents to the retired tooling/db/decision
  and sqlite-query CLIs and to decisions/*.md paths that moved to
  governance/ in the pql migration. They now name pql.
- The ticket-cli rule documented `pql decisions read`, which does not exist;
  `show` already includes the body.
- The culture authoring guide and the RON sources name
  `reach validate ron`, with the same arguments as before.
- The 41 Blender payloads' usage lines ran the retired tooling/blender
  wrapper, and the docstrings still cited pre-carve-out paths. They now read
  `reach blender run <payload>`.
- Doc comments in server/, client/, wiki TOMLs and the domain modules.

What is left is deliberate: "Formerly …" provenance, dated plans and findings
docs, the retired-pipeline doc, and a build-artefact path.

project.yaml 0.4.14 (mirrored to the client). Comment-only, but four touched
files are in the canvas-version registry (trait_catalog_reader.rs, since
T-1289, canvas_sources.py itself, and two client files). The gate is
path-based and has no override. The previous push was rejected on exactly
this.

Three of the edits are stamped ledger sources, so systems.db is regenerated
and the stamp is fresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:13:58 +02:00

524 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:
reach blender run blender_author_lower_shell \
<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()