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

445 lines
18 KiB
Python

"""
blender_author_outerwear.py (T-1089, jacket_modern — open-front outerwear family)
Companion to blender_author_offset_shell.py: authors OUTERWEAR offset-shells
(jacket / track-jacket / parka family) per body. It imports the base module and
reuses its scene/join/offset/solidify/mask/logo/export machinery; everything
garment-specific lives in the PARAMS block below, so a new outerwear piece is a
parameter delta, not a new script.
What it adds over the base t-shirt shell:
* FULL SLEEVES — covers seg_arm_lower_l/r too; the sleeve cut moves from the
upper arm to a WRIST cut on the lowerarm bones (same bone-plane technique).
* 4th REGION (A = zip/trim) — a front zip placket strip running hem -> through
the collar, plus knit cuff bands on the sleeve ends. Region layout per spec:
collar=R, body=G, sleeves=B, zip/trim=A.
* POSITION-PAINTED ALBEDO — instead of the base script's flat-noise albedo,
each body gets an albedo baked in its own UV0 layout by rasterizing every
face and painting per-texel from interpolated body-local position: zip teeth
dashes + placket, panel seam lines (shoulder, collar, cuff, placket edges),
hem band. The toon_garment shader recolors by LUMA, so the painted detail is
authored as luma contrast and survives any tint choice.
* LEFT-BREAST LOGO PATCH — the UV2 chest box shifts off-centre (the zip owns
the centre line), scaled per body from the same bone-landmark ratios.
Per-body only (this family is the poster child for the Q-060 per-body route):
reach blender run blender_author_outerwear \
<bodies_root> <out_dir> \
[--bodies a,b,c] [--offset 0.022] [--cuff-keep 0.88]
Writes per body: <out_dir>/<body>.glb (painted albedo embedded), <body>_mask.png
Plus: <out_dir>/base_albedo.png (average_m's painted albedo)
<out_dir>/reference_mask.png (copy of average_m's, fallback)
The per-body albedo is embedded in the GLB under the image name "base_albedo"
(tshirt convention): Godot's importer extracts it as <body>_base_albedo.png.
average_m is deliberately authored LAST so the shared base_albedo.png sidecar
ends up holding the reference body's pixels.
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
"""
import importlib.util
import os
import shutil
import sys
import bpy
import bmesh
import numpy as np
# --------------------------------------------------------------------------
# Load the base offset-shell module (shared engine machinery).
# --------------------------------------------------------------------------
_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
# --------------------------------------------------------------------------
# PARAMS — jacket_modern (casual zip jacket). A new outerwear garment should
# only need to touch this block (or override via CLI where exposed).
# --------------------------------------------------------------------------
# Full-arm coverage: torso + torso_upper + both whole arms.
SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
]
OFFSET_M = 0.022 # LARGEST standoff of the tops — worn over a shirt
# (tshirt_modern outer surface ~16 mm), must neither
# clip the body nor read skin-tight.
CLOTH_THICKNESS_M = 0.005 # beefier cloth than the 4 mm tee
CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept (wrist cut — hands show)
CUFF_BAND_FRAC = 0.22 # last fraction of the KEPT forearm = knit cuff trim (A)
# Region-shape ratios, anchored to the base module's average_m reference
# landmarks (shoulder |x| 0.1919, neck len 0.0793) so they scale per body.
ZIP_HALF_FRAC = 0.030 / 0.1919 # zip placket half-width as frac of shoulder |x|
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below neck head
# (t-shirt band was 0.3846 — jacket collar is taller,
# but 0.80 caught upper-chest faces and rendered as
# jagged shoulder "wings"; 0.55 keeps a neat band)
COLLAR_X_MULT = 1.15 # jacket collar slightly wider than the tee band
# Left-breast logo patch (character-left = +X; centre line belongs to the zip).
# Fracs of shoulder |x| / spine span, from average_m absolutes (0.035..0.115 m,
# z 1.30..1.42 m).
CHEST_X_LO_FRAC = 0.035 / 0.1919
CHEST_X_HI_FRAC = 0.115 / 0.1919
CHEST_Z_LO_FRAC = (1.30 - 1.072) / (1.5205 - 1.072)
CHEST_Z_HI_FRAC = (1.42 - 1.072) / (1.5205 - 1.072)
# Painted-albedo luma palette (the shader tints by luma: tint * (luma*1.5+0.2)).
BASE_LUMA = 0.58
PLACKET_LUMA = 0.50 # slightly darker front placket band
SEAM_LUMA = 0.38 # panel seam / stitch lines
TEETH_LUMA = 0.88 # bright zip teeth dashes
TEETH_GAP_LUMA = 0.34 # dark tape between dashes
CUFF_LUMA = 0.48 # knit cuff band
HEM_LUMA = 0.50 # bottom hem band
ALBEDO_NOISE = 0.02 # woven jitter
SEAM_W = 0.005 # seam line half-width, metres
TEETH_HALF_W = 0.006 # zip teeth strip half-width, metres
TEETH_PERIOD = 0.024 # dash period along Z, metres
TEETH_DUTY = 0.014 # bright dash length within a period, metres
HEM_BAND_M = 0.020 # hem band height, metres
# Slate hue applied on top of luma (only visible if the region mask is missing).
FABRIC_HUE = np.array([0.93, 0.97, 1.06], dtype=np.float32)
ALBEDO_SIZE = 512
# --------------------------------------------------------------------------
# Thresholds — base derivation + outerwear extras from the same armature
# --------------------------------------------------------------------------
def outerwear_thresholds(armature):
thr = base.derive_thresholds(armature)
bones = armature.data.bones
neck = bones.get("neck_01")
ua_l = bones.get("upperarm_l")
ua_r = bones.get("upperarm_r")
spine01 = bones.get("spine_01")
# Shoulder |x| (same landmark the base derivation uses).
if ua_l and ua_r:
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
else:
shoulder_x = 0.1919 # average_m fallback
# Taller, wider standing collar.
if neck:
neck_len = neck.tail_local.z - neck.head_local.z
thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
thr["collar_x_abs"] = thr["collar_x_abs"] * COLLAR_X_MULT
# Front zip placket.
thr["zip_half_x"] = shoulder_x * ZIP_HALF_FRAC
# Wrist cut planes + cuff trim band on the lowerarm bones.
cut_planes = []
cuff_abs = []
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
b = bones.get(bone_name)
if b is None:
log(f"WARNING: bone {bone_name} missing — sleeve left full-length")
continue
head_x = b.head_local.x
tail_x = b.tail_local.x
cut_x = head_x + CUFF_KEEP_FRAC * (tail_x - head_x)
band_x = head_x + (CUFF_KEEP_FRAC - CUFF_BAND_FRAC) * (tail_x - head_x)
cut_planes.append((sign, cut_x))
cuff_abs.append(abs(band_x))
log(f"wrist cut {bone_name}: keep |x| up to {cut_x:.3f} "
f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {band_x:.3f}")
thr["wrist_cut_planes"] = cut_planes
thr["cuff_x_abs"] = min(cuff_abs) if cuff_abs else 1e9
# Left-breast logo patch (replaces the base full-chest box).
thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC)
if neck and spine01:
spine_lo = spine01.head_local.z
span = neck.head_local.z - spine_lo
thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span,
spine_lo + CHEST_Z_HI_FRAC * span)
log(f"outerwear thresholds: zip half {thr['zip_half_x']:.3f} "
f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_x_abs']:.3f} "
f"cuff |x|>={thr['cuff_x_abs']:.3f} "
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
return thr
# --------------------------------------------------------------------------
# Wrist cut (bone-plane, same technique as the base sleeve cut)
# --------------------------------------------------------------------------
def wrist_cut(shell, thr):
"""Delete forearm verts beyond the wrist plane on each side."""
cut_planes = thr.get("wrist_cut_planes", [])
if not cut_planes:
log("WARNING: no wrist cut planes — sleeves stay full length")
return
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = []
for v in bm.verts:
for sign, thr_x in cut_planes:
if sign > 0 and v.co.x > thr_x:
to_delete.append(v)
break
if sign < 0 and v.co.x < thr_x:
to_delete.append(v)
break
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"wrist cut removed {len(to_delete)} verts; "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# 4-region classifier (collar=R, body=G, sleeves=B, zip/trim=A)
# --------------------------------------------------------------------------
def classify_region_outerwear(center, thr):
x, y, z = center.x, center.y, center.z
ax = abs(x)
front = y * base.FRONT_Y_SIGN > 0.0
# Zip placket first: runs hem -> THROUGH the collar (zip-through jacket).
if front and ax < thr["zip_half_x"]:
return (0.0, 0.0, 0.0, 1.0) # zip/trim -> A (tint[3])
if ax >= thr["cuff_x_abs"]:
return (0.0, 0.0, 0.0, 1.0) # knit cuff trim -> A (tint[3])
if ax >= thr["sleeve_x_abs"]:
return (0.0, 0.0, 1.0, 0.0) # sleeves -> B (tint[2])
if z >= thr["collar_z_min"] and ax < thr["collar_x_abs"]:
return (1.0, 0.0, 0.0, 0.0) # collar -> R (tint[0])
return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1])
# --------------------------------------------------------------------------
# Position-painted albedo (per-body UV0 bake)
# --------------------------------------------------------------------------
def _paint_luma(x, y, z, thr, z_min):
"""Vectorised luma paint from body-local position (arrays in, array out)."""
v = np.full(x.shape, BASE_LUMA, dtype=np.float32)
ax = np.abs(x)
front = (y * base.FRONT_Y_SIGN) > 0.0
# Hem band along the jacket bottom.
v = np.where(z < z_min + HEM_BAND_M, HEM_LUMA, v)
# Knit cuff bands.
v = np.where(ax >= thr["cuff_x_abs"], CUFF_LUMA, v)
# Panel seam lines: shoulder (sleeve boundary), cuff start, collar base.
v = np.where(np.abs(ax - thr["sleeve_x_abs"]) < SEAM_W, SEAM_LUMA, v)
v = np.where(np.abs(ax - thr["cuff_x_abs"]) < SEAM_W, SEAM_LUMA, v)
collar_seam = (np.abs(z - thr["collar_z_min"]) < SEAM_W) \
& (ax < thr["collar_x_abs"] * 1.2)
v = np.where(collar_seam, SEAM_LUMA, v)
# Front zip placket: darker band + stitch edges + dashed teeth line.
zh = thr["zip_half_x"]
v = np.where(front & (ax < zh), PLACKET_LUMA, v)
v = np.where(front & (np.abs(ax - zh) < SEAM_W * 0.6), SEAM_LUMA, v)
teeth = front & (ax < TEETH_HALF_W)
dash = np.mod(z, TEETH_PERIOD) < TEETH_DUTY
v = np.where(teeth & dash, TEETH_LUMA, v)
v = np.where(teeth & ~dash, TEETH_GAP_LUMA, v)
return v
def _raster_tri_painted(lum, uv_a, uv_b, uv_c, p_a, p_b, p_c, thr, z_min, W, H):
"""Barycentric fill of a UV triangle, painting per-texel from the
barycentrically interpolated body-local position."""
ax_, ay_ = uv_a.x * (W - 1), uv_a.y * (H - 1)
bx_, by_ = uv_b.x * (W - 1), uv_b.y * (H - 1)
cx_, cy_ = uv_c.x * (W - 1), uv_c.y * (H - 1)
minx = max(int(np.floor(min(ax_, bx_, cx_))), 0)
maxx = min(int(np.ceil(max(ax_, bx_, cx_))), W - 1)
miny = max(int(np.floor(min(ay_, by_, cy_))), 0)
maxy = min(int(np.ceil(max(ay_, by_, cy_))), H - 1)
if minx > maxx or miny > maxy:
return
denom = (by_ - cy_) * (ax_ - cx_) + (cx_ - bx_) * (ay_ - cy_)
if abs(denom) < 1e-9:
return
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
px = xs + 0.5
py = ys + 0.5
w0 = ((by_ - cy_) * (px - cx_) + (cx_ - bx_) * (py - cy_)) / denom
w1 = ((cy_ - ay_) * (px - cx_) + (ax_ - cx_) * (py - cy_)) / denom
w2 = 1.0 - w0 - w1
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
if not inside.any():
return
pos_x = w0 * p_a.x + w1 * p_b.x + w2 * p_c.x
pos_y = w0 * p_a.y + w1 * p_b.y + w2 * p_c.y
pos_z = w0 * p_a.z + w1 * p_b.z + w2 * p_c.z
vals = _paint_luma(pos_x, pos_y, pos_z, thr, z_min)
region = lum[miny:maxy + 1, minx:maxx + 1]
region[inside] = vals[inside]
def bake_painted_albedo(shell, thr, out_path, seed=1093):
"""Bake a per-body albedo: luma-painted detail in this body's UV0 layout."""
W = H = ALBEDO_SIZE
lum = np.full((H, W), BASE_LUMA, dtype=np.float32)
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 albedo bake")
z_min = min(v.co.z for v in bm.verts)
for face in bm.faces:
loops = face.loops[:]
uvs = [loop[uv_layer].uv.copy() for loop in loops]
pos = [loop.vert.co.copy() for loop in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_painted(lum, uvs[0], uvs[i], uvs[i + 1],
pos[0], pos[i], pos[i + 1], thr, z_min, W, H)
bm.free()
rng = np.random.default_rng(seed)
lum += (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
rgb = np.clip(lum[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0)
rgba = np.concatenate(
[rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2).astype(np.float32)
img = bpy.data.images.new("garment_painted_albedo", W, H, alpha=False)
img.pixels.foreach_set(rgba.reshape(-1))
img.update()
img.filepath_raw = out_path
img.file_format = 'PNG'
img.save()
log(f"baked painted albedo -> {out_path}")
return img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_outerwear(body_dir, out_dir, body, offset):
base.clear_scene()
shell, armature = base.build_covered_mesh(body_dir)
thr = outerwear_thresholds(armature)
wrist_cut(shell, thr)
base.offset_outward(shell, offset)
base.solidify(shell, CLOTH_THICKNESS_M)
# Shared sidecar name on purpose: the exporter derives the embedded glTF
# image name from the filepath basename, and Godot extracts embedded
# textures as <glb>_<image>.png — so this yields <body>_base_albedo.png
# on import, matching the tshirt_modern convention (no doubled names).
albedo_path = os.path.join(out_dir, "base_albedo.png")
albedo_img = bake_painted_albedo(shell, thr, albedo_path)
base.assign_fabric_material(shell, albedo_img)
base.author_logo_uv(shell, thr) # UV2 before mask bake (mask uses UV0/active)
base.bake_region_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr)
# Honest region tally (the base bake log can't see the A channel).
counts = {"collar": 0, "body": 0, "sleeve": 0, "zip/trim": 0}
bm = bmesh.new()
bm.from_mesh(shell.data)
for face in bm.faces:
c = classify_region_outerwear(face.calc_center_median(), thr)
if c[3] > 0.5:
counts["zip/trim"] += 1
elif c[2] > 0.5:
counts["sleeve"] += 1
elif c[0] > 0.5:
counts["collar"] += 1
else:
counts["body"] += 1
bm.free()
total = max(sum(counts.values()), 1)
log("outerwear regions: " + " ".join(
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print("Usage: -- <bodies_root> <out_dir> "
"[--bodies a,b,c] [--offset M] [--cuff-keep F]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
global CUFF_KEEP_FRAC
offset = OFFSET_M
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--cuff-keep" in argv:
CUFF_KEEP_FRAC = float(argv[argv.index("--cuff-keep") + 1])
bodies = base.BODY_TYPES
if "--bodies" in argv:
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
# Author the reference body LAST so the shared base_albedo.png sidecar
# (rewritten per body before each export) ends as average_m's version.
if base.REFERENCE_BODY in bodies:
bodies = [b for b in bodies if b != base.REFERENCE_BODY] + [base.REFERENCE_BODY]
os.makedirs(out_dir, exist_ok=True)
# Route the base mask bake through the 4-region outerwear classifier and
# the covered-segment import through the full-arm list.
base._classify_region = classify_region_outerwear
base.COVERED_SEGMENTS = SEGMENTS
log(f"outerwear per-body mode: {len(bodies)} bodies, "
f"offset {offset*1000:.0f} mm, cuff keep {CUFF_KEEP_FRAC:.2f}")
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_outerwear(body_dir, out_dir, body, offset)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
results.append((body, f"error: {exc}"))
# Runtime fallback: reference_mask.png mirrors average_m's mask.
# (base_albedo.png already holds average_m's albedo — it authored last.)
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()