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>
426 lines
17 KiB
Python
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()
|