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

426 lines
17 KiB
Python

"""
blender_author_buttondown.py (T-1089, buttondown_modern — per-body offset shell)
Button-down shirt authored per body via the offset-shell route. Imports
blender_author_offset_shell.py as a module and reuses its scene/build/offset/
solidify/logo-UV/export helpers; this file adds what the button-down needs
beyond the t-shirt base:
* long-sleeve coverage — torso + torso_upper + BOTH full arms (upper+lower),
with a wrist-plane cut derived from the lowerarm bone (SLEEVE_END_FRAC),
replacing the base script's upper-arm short-sleeve cut
* seam WELD after join — the body segments share exact duplicated boundary
rings (probe: 24-38 coincident verts/seam, zero near-misses), so a
remove-doubles pass gives continuous normals across segment seams ->
crack-free outward offset and no internal solidify rims (UVs live on
loops, so UV seams survive the weld; weights are identical by construction)
* per-PIXEL region mask bake (barycentric-interpolated body-local positions)
instead of the base per-face classification — the placket and cuff
boundaries cut through the middle of low-poly faces
* PAINTED albedo — texture-carried identity per the style pin: placket band
with stitch lines, button dots, collar + cuff seam lines, over the flat
toon fabric noise. Baked per body because each body archetype has its own
UV atlas (the shell inherits body UVs).
Region convention (spec): collar=R (tint_0), body=G (tint_1),
cuffs+placket=B (tint_2). A unused. Logo-capable OFF, but the UV2 chest
channel is still authored (costs nothing; enables future brand variants).
Run (per-body only — this garment ships the per-body route):
reach blender run blender_author_buttondown \
client/assets/characters/bodies \
client/assets/characters/clothing/buttondown_modern \
[--bodies average_m,child,...] [--offset 0.009]
Writes per body: <out_dir>/<body>.glb + <out_dir>/<body>_mask.png
Plus: <out_dir>/base_albedo.png (average_m's painted albedo)
<out_dir>/reference_mask.png (copy of average_m_mask.png)
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
(per-body authoring for offset shells).
"""
import os
import shutil
import sys
import bmesh
import bpy
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import blender_author_offset_shell as base # noqa: E402
# --------------------------------------------------------------------------
# Garment parameters (average_m metres; scaled per body by shoulder ratio)
# --------------------------------------------------------------------------
COVERED_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.009 # slimmer standoff than the tee (12 mm) — dress shirt
CLOTH_THICKNESS_M = 0.003 # thinner cloth than the tee (4 mm)
SLEEVE_END_FRAC = 0.92 # of lowerarm bone length — long sleeve, ends above wrist
CUFF_START_FRAC = 0.62 # of lowerarm bone length — last ~38% of forearm = cuff (B)
WELD_DIST = 0.0002 # merge duplicated segment-boundary rings (0.2 mm)
MASK_SIZE = 512
ALBEDO_SIZE = 1024 # painted detail (buttons ~8 px radius) needs 1024
# Style dimensions on average_m (shoulder |x| = base._REF_SHOULDER_X);
# scaled per body by shoulder_x ratio so proportions hold from child to heavy_m.
PLACKET_HALF_M = 0.022 # placket half-width (4.4 cm total band)
STITCH_W_M = 0.0025 # stitch/seam line half-width
BUTTON_R_M = 0.0095 # button disc radius (chunky toon read)
SEAM_HALF_M = 0.0022 # collar seam line half-height
BUTTON_COUNT = 6
FRONT_MIN_Y = 0.004 # body-local front test: y * FRONT_Y_SIGN > this
# Flat toon oxford base tone + painted feature colours (luma carries through
# the luminance-preserving region tint in toon_garment.gdshader).
FABRIC_RGB = (0.63, 0.64, 0.66)
FABRIC_NOISE = 0.02
PLACKET_BAND_MUL = 1.07 # subtle lift so the band reads under any tint
STITCH_MUL = 0.52 # dark stitch lines
SEAM_MUL = 0.55 # collar/cuff seam lines
BUTTON_RGB_OUTER = (0.10, 0.10, 0.12)
BUTTON_RGB_CORE = (0.24, 0.24, 0.27)
def log(msg):
print(f"[buttondown] {msg}")
# --------------------------------------------------------------------------
# Threshold derivation (extends base.derive_thresholds with arm + style dims)
# --------------------------------------------------------------------------
def derive_arm_thresholds(armature):
"""Wrist cut planes + cuff start from the lowerarm bones (X-axis arms)."""
bones = armature.data.bones
la_l = bones.get("lowerarm_l")
la_r = bones.get("lowerarm_r")
if la_l is None or la_r is None:
raise RuntimeError("lowerarm bones missing — cannot derive sleeve cut")
def along(b, frac):
return b.head_local.x + frac * (b.tail_local.x - b.head_local.x)
cut_l = along(la_l, SLEEVE_END_FRAC) # left arm +x: delete x > cut_l
cut_r = along(la_r, SLEEVE_END_FRAC) # right arm -x: delete x < cut_r
cuff_x_abs = (abs(along(la_l, CUFF_START_FRAC))
+ abs(along(la_r, CUFF_START_FRAC))) / 2.0
log(f"sleeve: cut_l={cut_l:.3f} cut_r={cut_r:.3f} cuff |x|>={cuff_x_abs:.3f}")
return {"cut_l": cut_l, "cut_r": cut_r, "cuff_x_abs": cuff_x_abs}
def derive_style(armature):
"""Scale the painted-detail dimensions by this body's shoulder ratio."""
bones = armature.data.bones
ua_l = bones.get("upperarm_l")
ua_r = bones.get("upperarm_r")
if ua_l is None or ua_r is None:
s = 1.0
else:
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
s = shoulder_x / base._REF_SHOULDER_X
log(f"style scale {s:.3f}")
return {
"placket_half": PLACKET_HALF_M * s,
"stitch_w": STITCH_W_M * s,
"button_r": BUTTON_R_M * s,
"seam_half": SEAM_HALF_M * s,
}
def derive_buttons(shell, thr):
"""Button Z positions: evenly spaced down the placket (collar -> hem)."""
hem_z = min(v.co.z for v in shell.data.vertices)
collar_z = thr["collar_z_min"]
span = collar_z - hem_z
z_top = collar_z - 0.05 * span
z_bot = hem_z + 0.07 * span
zs = list(np.linspace(z_top, z_bot, BUTTON_COUNT))
log(f"buttons: {BUTTON_COUNT} @ z {z_bot:.3f}..{z_top:.3f} (hem {hem_z:.3f})")
return {"button_zs": zs, "hem_z": hem_z}
# --------------------------------------------------------------------------
# Geometry: seam weld + wrist cut
# --------------------------------------------------------------------------
def weld_seams(shell):
"""Merge the duplicated segment-boundary rings into one continuous shell."""
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST)
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"seam weld: {before} -> {len(shell.data.vertices)} verts "
f"({before - len(shell.data.vertices)} merged)")
def wrist_cut(shell, thr):
"""Delete sleeve verts beyond the wrist plane on each forearm."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = [v for v in bm.verts
if v.co.x > thr["cut_l"] or v.co.x < thr["cut_r"]]
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")
# --------------------------------------------------------------------------
# Per-pixel bake: region mask + painted albedo in one pass
# --------------------------------------------------------------------------
def _gather_tris(shell):
"""Fan-triangulate every face into (uv_a, uv_b, uv_c, co_a, co_b, co_c)."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
uvl = bm.loops.layers.uv[me.uv_layers[0].name] # UV0 = shared body atlas
tris = []
for face in bm.faces:
loops = face.loops[:]
uvs = [np.array((lp[uvl].uv.x, lp[uvl].uv.y)) for lp in loops]
cos = [np.array(lp.vert.co[:]) for lp in loops]
for i in range(1, len(loops) - 1):
tris.append((uvs[0], uvs[i], uvs[i + 1],
cos[0], cos[i], cos[i + 1]))
bm.free()
return tris
def _tri_cover(a, b, c, W, H):
"""Pixel centres covered by UV triangle abc -> (ys, xs, w0, w1, w2)."""
ax, ay = a[0] * (W - 1), a[1] * (H - 1)
bx, by = b[0] * (W - 1), b[1] * (H - 1)
cx, cy = c[0] * (W - 1), c[1] * (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 None
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
if abs(denom) < 1e-9:
return None # degenerate (solidify rim) — no UV area to write
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 None
return (ys[inside].ravel(), xs[inside].ravel(),
w0[inside].ravel(), w1[inside].ravel(), w2[inside].ravel())
def _interp_pos(cover, co_a, co_b, co_c):
_, _, w0, w1, w2 = cover
return (w0[:, None] * co_a[None, :]
+ w1[:, None] * co_b[None, :]
+ w2[:, None] * co_c[None, :])
def _classify_px(pos, thr):
"""Vectorized region classification -> (N,4) one-hot RGBA rows."""
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
ax = np.abs(x)
front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y
cuff = ax >= thr["cuff_x_abs"]
collar = (~cuff) & (z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"])
placket = ((~cuff) & (~collar) & front
& (ax <= thr["placket_half"]) & (z < thr["collar_z_min"]))
rgba = np.zeros((len(x), 4), dtype=np.float32)
rgba[:, 1] = 1.0 # default: body -> G
rgba[collar] = (1.0, 0.0, 0.0, 0.0) # collar band -> R
rgba[cuff | placket] = (0.0, 0.0, 1.0, 0.0) # cuffs + placket -> B
return rgba
def _paint_px(pos, rows, thr):
"""Painted albedo: placket band + stitches, seams, button dots (in-place)."""
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
ax = np.abs(x)
front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y
below_collar = z < thr["collar_z_min"]
mul = np.ones(len(x), dtype=np.float32)
band = front & below_collar & (ax <= thr["placket_half"])
mul[band] = PLACKET_BAND_MUL
stitch = front & below_collar & (np.abs(ax - thr["placket_half"])
<= thr["stitch_w"])
mul[stitch] = STITCH_MUL
collar_seam = ((np.abs(z - thr["collar_z_min"]) <= thr["seam_half"])
& (ax < thr["collar_x_abs"] * 1.8))
mul[collar_seam] = SEAM_MUL
cuff_seam = np.abs(ax - thr["cuff_x_abs"]) <= thr["stitch_w"]
mul[cuff_seam] = SEAM_MUL
out = rows * mul[:, None]
r = thr["button_r"]
for bz in thr["button_zs"]:
d2 = x * x + (z - bz) ** 2
disc = front & (d2 <= r * r)
out[disc] = BUTTON_RGB_OUTER
core = front & (d2 <= (0.45 * r) ** 2)
out[core] = BUTTON_RGB_CORE
np.clip(out, 0.0, 1.0, out)
return out
def bake_maps(shell, thr, mask_path):
"""One pass over the shell: bake <body>_mask.png + return painted albedo."""
tris = _gather_tris(shell)
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
mbuf[:, :, 1] = 1.0 # green background = main body (bilinear-bleed safe)
rng = np.random.default_rng(1090) # deterministic fabric noise
fabric = np.array(FABRIC_RGB, dtype=np.float32)
noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
- 0.5) * 2.0 * FABRIC_NOISE
abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
cover = _tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
if cover is not None:
pos = _interp_pos(cover, co_a, co_b, co_c)
mbuf[cover[0], cover[1], :] = _classify_px(pos, thr)
cover = _tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
if cover is not None:
pos = _interp_pos(cover, co_a, co_b, co_c)
abuf[cover[0], cover[1], :] = _paint_px(
pos, abuf[cover[0], cover[1], :], thr)
tot = MASK_SIZE * MASK_SIZE
log("mask texels: collar={:.1f}% body={:.1f}% cuff+placket={:.1f}%".format(
100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
mask_img = bpy.data.images.new("garment_region_mask", MASK_SIZE, MASK_SIZE,
alpha=True)
mask_img.pixels.foreach_set(mbuf.reshape(-1))
mask_img.update()
mask_img.filepath_raw = mask_path
mask_img.file_format = 'PNG'
mask_img.save()
log(f"baked region mask -> {mask_path}")
argba = np.concatenate(
[abuf, np.ones((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)],
axis=2)
albedo_img = bpy.data.images.new("base_albedo", ALBEDO_SIZE, ALBEDO_SIZE,
alpha=False)
albedo_img.pixels.foreach_set(argba.reshape(-1))
albedo_img.update()
return albedo_img
# --------------------------------------------------------------------------
# Author one body
# --------------------------------------------------------------------------
def author_buttondown(body_dir, out_dir, body, offset):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS # long-sleeve coverage set
shell, armature = base.build_covered_mesh(body_dir)
thr = base.derive_thresholds(armature) # collar/chest from bone landmarks
thr.update(derive_arm_thresholds(armature))
thr.update(derive_style(armature))
weld_seams(shell)
wrist_cut(shell, thr)
thr.update(derive_buttons(shell, thr))
base.offset_outward(shell, offset)
base.solidify(shell, CLOTH_THICKNESS_M)
base.author_logo_uv(shell, thr) # UV2 before bake (mask/albedo use UV0)
albedo_img = bake_maps(shell, thr, os.path.join(out_dir, f"{body}_mask.png"))
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
base.assign_fabric_material(shell, albedo_img)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
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]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = OFFSET_M
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 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)
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.1f} mm")
avg_albedo_stash = os.path.join(out_dir, "_tmp_avg_base_albedo.png")
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_buttondown(body_dir, out_dir, body, offset)
if body == base.REFERENCE_BODY:
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
avg_albedo_stash)
results.append((body, "ok"))
except Exception as exc: # noqa: BLE001 — per-body isolation
log(f"ERROR {body}: {exc}")
results.append((body, f"error: {exc}"))
# Root sidecars: reference mask + albedo mirror the reference body.
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")
if os.path.isfile(avg_albedo_stash):
shutil.move(avg_albedo_stash, os.path.join(out_dir, "base_albedo.png"))
log(f"base_albedo.png = {base.REFERENCE_BODY}'s painted albedo")
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()