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

597 lines
24 KiB
Python

"""
blender_author_sweater.py (T-1089 wave 2 — sweater_modern, per-body offset shell)
Crew-neck pullover authored per body via the offset-shell route. Imports
blender_author_offset_shell.py as the shared library (segment join, bone-ratio
thresholds, offset, solidify, logo UV2, GLB export) and
blender_author_denim_pants.py for the wave-1-proven boundary practices
(coincident segment-seam WELD, seg_hips crotch probe). What the sweater adds,
as reusable parameters:
* coverage: torso + torso_upper + FULL arms (cuffs at the wrist) + the UPPER
seg_hips band. Including seg_hips is the hem answer for tops: the natural
seg_torso bottom boundary is a 9-14 cm jagged tooth ring (probe evidence,
all bodies) and flattening it up/down either opens skin holes (hips skin
fails to reach the teeth tops in 2/24 azimuth bins) or invents cloth off
the body surface. Instead the shell continues into real hip geometry and
is hem-CUT at a clean plane derived from bone landmarks (spine_01 ->
crotch fraction), then the cut ring is flattened onto that plane
(denim-pants ankle practice; pull distance ~ one face, not full teeth).
Hips stay UNHIDDEN in coverage.json — skin continues under the hem, so no
hole is geometrically possible (uniform_utility waist-join precedent).
* crew-neck CUT + rim CIRCULARIZATION — the seg_torso_upper neckline is a
jagged tooth ring whose VALLEY sits at neck_z + ~0.23*neck_len on every
body (probe evidence, 6 bodies) — exactly crew height — while its teeth
reach ~72% up the neck. Raising the rim to the teeth max (the first
draft) reads as a chin-high mock-neck, not the crew the spec asks. So:
delete the neck-tube verts ABOVE the valley within the ring's own
measured hd_max * CUT_R_MUL (per-body self-calibrating radius — the
neck-trap flare shell probes strictly outside it), then flatten the new
boundary onto the valley plane: verts within r_med * CREW_RIM_R_MUL of
the neck axis are circularized to the ring median radius (clean crew
rim); flare verts beyond only drop to the plane (no radial pinch, which
would fold cloth on wide-trap bodies). Skin-hole-safe: seg_neck skin
reaches down exactly to the valley (probe), and runtime never hides
segments anyway.
* wrist rim flattening onto the cut plane (denim ankle practice on the arm
axis) — the vert-threshold cut leaves 1-3 cm teeth; the painted ribbed
cuff needs a clean edge.
* cuff PINCH — per-vert offset scale tapering to CUFF_PINCH at the wrist so
the ribbed cuffs read cinched (knit hugs the wrist).
* painted knit identity, driven per texel from final 3D positions
(buttondown per-pixel bake machinery): ribbed collar / cuffs / hem —
azimuthal rib stripes around the neck axis, arm axis, and torso axis —
plus border stitch lines. All LUMINANCE detail, so the luma-preserving
toon_garment recolor keeps it under any tint. Warm muted default tints
live in the manifest entry, not the albedo.
* region mask (spec): collar=R (tint_0), body=G (tint_1), cuffs+hem=B
(tint_2). A unused. Logo-capable OFF per spec, but the UV2 chest channel
is still authored (costs nothing; buttondown precedent).
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
reach blender run blender_author_sweater \
client/assets/characters/bodies \
client/assets/characters/clothing/sweater_modern \
[--bodies average_m,child,...] [--offset 0.014] [--hem-drop 0.30] \
[--rib-period 0.011] [--base-rgb 0.62,0.585,0.545]
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
<out_dir>/<body>_base_albedo.png
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
<out_dir>/reference_mask.png (average_m's, runtime fallback)
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
"""
import math
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
import blender_author_denim_pants as denim # noqa: E402 (weld + hips probe)
import blender_author_buttondown as bd # noqa: E402 (per-pixel bake helpers)
# --------------------------------------------------------------------------
# Parameters (the reusable seam — override for cardigan/turtleneck variants)
# --------------------------------------------------------------------------
GARMENT_ID = "sweater_modern"
SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
"seg_hips",
]
OFFSET_M = 0.014 # knit-weight standoff (tee 12 / hoodie 16)
THICKNESS_M = 0.005 # knit cloth thickness (tee 4 / hoodie 6)
WRIST_FRAC = 0.94 # fraction of lowerarm kept — cuffs AT the wrist
CUFF_PINCH = 0.75 # offset scale at the wrist rim (ribbing cinches)
CUFF_LEN_FRAC = 0.18 # ribbed cuff length, fraction of lowerarm length
HEM_DROP_FRAC = 0.30 # hem below the spine_01 waistline, fraction of
# (waistline - crotch) — hip-length pullover
HEM_BAND_FRAC = 0.085 # ribbed hem band, fraction of (collar_z - hem_z)
COLLAR_REACH_FRAC = 1.45 # collar region radial reach, x collar_x_abs
CUT_R_MUL = 1.05 # crew cut radius, x the neck ring's measured hd_max
CREW_RIM_R_MUL = 1.6 # circularize rim verts within this x ring r_med
COLLAR_BAND_FRAC = 0.32 # ribbed collar band height below the crew rim,
# x neck_len (~2.5 cm on average_m) — anchored to
# the MEASURED rim, not collar_z_min, so the band
# reads as a crew rib, not a chest yoke
# Knit paint (luminance detail; absolute mm scaled by the body's shoulder
# ratio so the rib gauge reads identical from child to heavy_m).
RIB_PERIOD_M = 0.011 # one rib pair (dark+light) around the band
RIB_DARK = 0.85 # dark rib stripe multiplier
BAND_MUL = 0.94 # overall band shade vs body knit
LINE_MUL = 0.70 # border stitch line multiplier
LINE_HALF_M = 0.0022 # border stitch line half-width
# Warm muted base fabric (default tints in the manifest carry the colour;
# the albedo carries luma detail + the untinted fallback tone).
FABRIC_RGB = (0.620, 0.585, 0.545)
FABRIC_NOISE = 0.030
ALBEDO_SEED = 2093
MASK_SIZE = 512
ALBEDO_SIZE = 1024
def log(msg):
print(f"[sweater] {msg}")
# --------------------------------------------------------------------------
# Landmarks (all bone-proportional; same philosophy as base.derive_thresholds)
# --------------------------------------------------------------------------
def derive_landmarks(armature, thr, crotch_z):
bones = armature.data.bones
neck = bones.get("neck_01")
la_l = bones.get("lowerarm_l")
la_r = bones.get("lowerarm_r")
spine01 = bones.get("spine_01")
ua_l = bones.get("upperarm_l")
ua_r = bones.get("upperarm_r")
if not all([neck, la_l, la_r, spine01, ua_l, ua_r]):
raise RuntimeError("landmark bones missing (neck_01/lowerarm/spine_01)")
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
s = shoulder_x / base._REF_SHOULDER_X
def along(b, frac):
return b.head_local.x + frac * (b.tail_local.x - b.head_local.x)
cut_l = along(la_l, WRIST_FRAC) # left arm +x: delete x > cut_l
cut_r = along(la_r, WRIST_FRAC) # right arm -x: delete x < cut_r
lowerarm_len = abs(la_l.tail_local.x - la_l.head_local.x)
wrist_x_abs = (abs(cut_l) + abs(cut_r)) / 2.0
waist_z = spine01.head_local.z
hem_z = waist_z - HEM_DROP_FRAC * (waist_z - crotch_z)
# Arm rib axis (y, z) — midpoint of the lowerarm bone, shared by both
# sides (the rig mirrors in x only).
az_y = (la_l.head_local.y + la_l.tail_local.y) / 2.0
az_z = (la_l.head_local.z + la_l.tail_local.z) / 2.0
lm = {
"style": s,
"neck_y": neck.head_local.y,
"neck_len": neck.tail_local.z - neck.head_local.z,
"cut_l": cut_l,
"cut_r": cut_r,
"wrist_x_abs": wrist_x_abs,
"cuff_x0": wrist_x_abs - CUFF_LEN_FRAC * lowerarm_len,
"arm_axis_yz": (az_y, az_z),
"waist_z": waist_z,
"crotch_z": crotch_z,
"hem_z": hem_z,
"collar_reach": COLLAR_REACH_FRAC * thr["collar_x_abs"],
"rib_period": RIB_PERIOD_M * s,
"line_half": LINE_HALF_M * s,
}
lm["hem_top"] = hem_z + HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
log(f"landmarks: style {s:.3f} wrist cuts ({cut_l:.3f},{cut_r:.3f}) "
f"cuff_x0 {lm['cuff_x0']:.3f} hem {hem_z:.3f} (waist {waist_z:.3f}, "
f"crotch {crotch_z:.3f}) hem_top {lm['hem_top']:.3f} "
f"collar reach {lm['collar_reach']:.3f}")
return lm
# --------------------------------------------------------------------------
# Geometry: wrist + hem cuts, rim flattening, crew-collar circularization
# --------------------------------------------------------------------------
def wrist_cut(shell, lm):
"""Trim the sleeve tubes at the wrist plane (hoodie/jacket pattern)."""
bm = bmesh.new()
bm.from_mesh(shell.data)
doomed = [v for v in bm.verts
if v.co.x > lm["cut_l"] or v.co.x < lm["cut_r"]]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"wrist cut removed {len(doomed)} verts; "
f"{len(shell.data.vertices)} remain")
def hem_cut(shell, lm):
"""Trim the hips continuation below the hem plane (real hip geometry, so
the surviving cloth conforms to the body — no invented coverage)."""
bm = bmesh.new()
bm.from_mesh(shell.data)
doomed = [v for v in bm.verts if v.co.z < lm["hem_z"]]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"hem cut at z={lm['hem_z']:.3f}: removed {len(doomed)} verts")
def crew_cut(shell, thr, lm):
"""Cut the neck tube down to crew height (probe-driven, per body).
The seg_torso_upper neck boundary is a jagged tooth ring: valley at
~neck_z + 0.23*neck_len (crew height), teeth up to ~72% of the neck.
Measure the ring (valley z, median + max horizontal distance to the neck
axis), then delete every vert ABOVE the valley within hd_max * CUT_R_MUL
of the axis — the tube and its teeth, and provably not the neck-trap
flare shell (probe: flare hd sits well outside hd_max on all bodies).
Stores crew_z / crew_r in lm for the flatten pass.
"""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
boundary = set()
for e in bm.edges:
if len(e.link_faces) == 1:
boundary.update(v.index for v in e.verts)
mid_l = 0.5 * (thr["sleeve_x_abs"] + lm["cut_l"])
mid_r = -0.5 * (thr["sleeve_x_abs"] + abs(lm["cut_r"]))
z_split = 0.5 * (lm["hem_z"] + thr["collar_z_min"])
ring = []
for i in boundary:
v = bm.verts[i]
if v.co.x > mid_l or v.co.x < mid_r or v.co.z <= z_split:
continue
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
if hd <= lm["collar_reach"] * 1.5:
ring.append((hd, v.co.z))
if not ring:
bm.free()
raise RuntimeError("no neck boundary ring found for crew cut")
lm["crew_z"] = min(z for _, z in ring)
lm["crew_r"] = float(np.median([hd for hd, _ in ring]))
hd_max = max(hd for hd, _ in ring)
r_cut = hd_max * CUT_R_MUL
doomed = [v for v in bm.verts
if v.co.z > lm["crew_z"]
and math.hypot(v.co.x, v.co.y - lm["neck_y"]) <= r_cut]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(me)
bm.free()
me.update()
lm["collar_band_z0"] = lm["crew_z"] - COLLAR_BAND_FRAC * lm["neck_len"]
log(f"crew cut: ring valley z={lm['crew_z']:.3f} r_med={lm['crew_r']:.3f} "
f"hd_max={hd_max:.3f} -> removed {len(doomed)} tube verts "
f"(r_cut {r_cut:.3f}); collar band z0={lm['collar_band_z0']:.3f}")
def flatten_rims_and_collar(shell, thr, lm):
"""Clean every open rim (adapted from the denim flatten_open_rims
practice; only boundary verts move, weights/UVs ride along).
wrist rings -> the wrist cut planes (x per side)
hem ring -> the hem plane (z)
neck ring -> the crew plane (crew_z from crew_cut): verts within
crew_r * CREW_RIM_R_MUL of the neck axis circularize to
the ring median radius (clean crew rim); flare verts
beyond only drop onto the plane — no radial pinch, which
would fold cloth on wide-trap bodies.
"""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
boundary = set()
for e in bm.edges:
if len(e.link_faces) == 1:
boundary.update(v.index for v in e.verts)
mid_l = 0.5 * (thr["sleeve_x_abs"] + lm["cut_l"])
mid_r = -0.5 * (thr["sleeve_x_abs"] + abs(lm["cut_r"]))
z_split = 0.5 * (lm["hem_z"] + thr["collar_z_min"])
wrist_n = hem_n = rim_n = flare_n = 0
stray = 0
for i in boundary:
v = bm.verts[i]
if v.co.x > mid_l:
v.co.x = lm["cut_l"]
wrist_n += 1
elif v.co.x < mid_r:
v.co.x = lm["cut_r"]
wrist_n += 1
elif v.co.z <= z_split:
v.co.z = lm["hem_z"]
hem_n += 1
else:
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
if hd <= lm["crew_r"] * CREW_RIM_R_MUL:
if hd > 1e-6:
f = lm["crew_r"] / hd
v.co.x *= f
v.co.y = lm["neck_y"] + (v.co.y - lm["neck_y"]) * f
v.co.z = lm["crew_z"]
rim_n += 1
elif hd <= lm["collar_reach"] * 1.5:
v.co.z = lm["crew_z"]
flare_n += 1
else:
stray += 1
if rim_n == 0:
log("WARNING: no crew rim verts found — collar left ragged")
log(f"flattened rims: wrist {wrist_n} verts -> cut planes, "
f"hem {hem_n} -> z={lm['hem_z']:.3f}, crew rim {rim_n} -> circle "
f"r={lm['crew_r']:.3f} z={lm['crew_z']:.3f}, flare {flare_n} -> "
f"plane, stray {stray}")
bm.to_mesh(me)
bm.free()
me.update()
def offset_with_cuff_pinch(shell, offset, lm):
"""Outward normal offset with the standoff tapering to CUFF_PINCH over
the ribbed cuff band (knit cuffs hug the wrist)."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.normal_update()
span = max(lm["wrist_x_abs"] - lm["cuff_x0"], 1e-6)
pinched = 0
for v in bm.verts:
s = 1.0
ax = abs(v.co.x)
if ax > lm["cuff_x0"]:
t = min((ax - lm["cuff_x0"]) / span, 1.0)
s = 1.0 - (1.0 - CUFF_PINCH) * t
pinched += 1
v.co += v.normal * (offset * s)
bm.to_mesh(me)
bm.free()
me.update()
log(f"offset {offset*1000:.0f} mm outward "
f"({pinched} cuff verts pinched to {CUFF_PINCH:.2f}x)")
# --------------------------------------------------------------------------
# Per-pixel knit paint + region mask (buttondown bake machinery)
# --------------------------------------------------------------------------
def _rib_stripes(arc, period):
"""Bool array: dark rib stripe (half of each rib pair)."""
return np.mod(arc / period, 1.0) < 0.5
def _classify_px(pos, thr, lm):
"""Region mask rows: collar=R, body=G, cuffs+hem=B (spec)."""
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
ax = np.abs(x)
hd = np.hypot(x, y - lm["neck_y"])
collar = (z >= lm["collar_band_z0"]) & (hd <= lm["collar_reach"])
band_b = (~collar) & ((ax >= lm["cuff_x0"]) | (z <= lm["hem_top"]))
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) # crew collar -> R
rgba[band_b] = (0.0, 0.0, 1.0, 0.0) # cuffs + hem -> B
return rgba
def _paint_px(pos, rows, thr, lm):
"""Knit luminance detail: ribbed collar/cuff/hem + border stitch lines."""
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
ax = np.abs(x)
hd = np.hypot(x, y - lm["neck_y"])
period = lm["rib_period"]
line = lm["line_half"]
collar = (z >= lm["collar_band_z0"]) & (hd <= lm["collar_reach"])
cuff = (~collar) & (ax >= lm["cuff_x0"])
hem = (~collar) & (~cuff) & (z <= lm["hem_top"])
mul = np.ones(len(x), dtype=np.float32)
mul[collar | cuff | hem] = BAND_MUL
# Collar ribs: azimuth around the neck axis. atan2's +-pi discontinuity
# sits where the 2nd arg is negative — (y-cy)*FRONT_Y_SIGN < 0 is the
# BACK on this rig, so the rib wrap seam hides at centre-back.
arc = np.arctan2(x, (y - lm["neck_y"]) * base.FRONT_Y_SIGN) * hd
sel = collar & _rib_stripes(arc, period)
mul[sel] *= RIB_DARK
# Cuff ribs: azimuth around the arm axis (wrap seam under the arm).
ay, az_ = lm["arm_axis_yz"]
r_arm = np.hypot(y - ay, z - az_)
arc = np.arctan2(y - ay, z - az_) * r_arm
sel = cuff & _rib_stripes(arc, period)
mul[sel] *= RIB_DARK
# Hem ribs: azimuth around the torso axis (wrap seam at centre-back).
r_t = np.hypot(x, y - lm["hem_cy"])
arc = np.arctan2(x, (y - lm["hem_cy"]) * base.FRONT_Y_SIGN) * r_t
sel = hem & _rib_stripes(arc, period)
mul[sel] *= RIB_DARK
# Border stitch lines at each band's inner edge.
mul[(np.abs(z - lm["collar_band_z0"]) <= line)
& (hd <= lm["collar_reach"] * 1.1)] = LINE_MUL
mul[np.abs(ax - lm["cuff_x0"]) <= line] = LINE_MUL
mul[(np.abs(z - lm["hem_top"]) <= line) & (~cuff)] = LINE_MUL
out = rows * mul[:, None]
np.clip(out, 0.0, 1.0, out)
return out
def bake_maps(shell, thr, lm, mask_path):
"""One pass over UV0 triangles: bake <body>_mask.png + painted albedo."""
tris = bd._gather_tris(shell)
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
mbuf[:, :, 1] = 1.0 # body-green background (bilinear-bleed safe)
rng = np.random.default_rng(ALBEDO_SEED)
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)
# Hem torso-axis centre from the final mesh (paint anchor).
hem_ys = [v.co.y for v in shell.data.vertices if v.co.z <= lm["hem_top"]]
lm["hem_cy"] = float(np.mean(hem_ys)) if hem_ys else 0.0
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
cover = bd._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
if cover is not None:
pos = bd._interp_pos(cover, co_a, co_b, co_c)
mbuf[cover[0], cover[1], :] = _classify_px(pos, thr, lm)
cover = bd._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
if cover is not None:
pos = bd._interp_pos(cover, co_a, co_b, co_c)
abuf[cover[0], cover[1], :] = _paint_px(
pos, abuf[cover[0], cover[1], :], thr, lm)
tot = MASK_SIZE * MASK_SIZE
log("mask texels: collar={:.1f}% body={:.1f}% cuffs+hem={:.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(f"{GARMENT_ID}_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(f"{GARMENT_ID}_albedo", ALBEDO_SIZE,
ALBEDO_SIZE, alpha=False)
albedo_img.pixels.foreach_set(argba.reshape(-1))
albedo_img.update()
return albedo_img
def save_albedo(albedo_img, out_dir, body):
"""Sidecar + embed naming (hoodie convention): the glTF exporter names the
embedded image after the file basename and Godot extracts it as
<glb>_<imagename>.png, so pointing the image at base_albedo.png makes the
extraction land exactly on <body>_base_albedo.png."""
sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
albedo_img.filepath_raw = sidecar
albedo_img.file_format = 'PNG'
albedo_img.save()
log(f"painted albedo -> {sidecar}")
albedo_img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
albedo_img.save()
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_sweater(body_dir, out_dir, body, offset):
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
base.clear_scene()
base.COVERED_SEGMENTS = SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
thr = base.derive_thresholds(armature)
lm = derive_landmarks(armature, thr, crotch_z)
denim.weld_boundaries(shell)
wrist_cut(shell, lm)
hem_cut(shell, lm)
crew_cut(shell, thr, lm)
flatten_rims_and_collar(shell, thr, lm)
offset_with_cuff_pinch(shell, offset, lm)
base.solidify(shell, THICKNESS_M)
base.author_logo_uv(shell, thr) # UV2 before bakes (mask/albedo use UV0)
albedo_img = bake_maps(shell, thr, lm,
os.path.join(out_dir, f"{body}_mask.png"))
save_albedo(albedo_img, out_dir, body)
base.assign_fabric_material(shell, albedo_img)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
def main():
global OFFSET_M, HEM_DROP_FRAC, RIB_PERIOD_M, FABRIC_RGB
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] [--hem-drop F] [--rib-period M] "
"[--base-rgb r,g,b]")
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])
if "--hem-drop" in argv:
HEM_DROP_FRAC = float(argv[argv.index("--hem-drop") + 1])
if "--rib-period" in argv:
RIB_PERIOD_M = float(argv[argv.index("--rib-period") + 1])
if "--base-rgb" in argv:
FABRIC_RGB = tuple(
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
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 sweater: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
f"hem-drop {HEM_DROP_FRAC}")
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_sweater(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}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
# Runtime fallbacks mirror the reference body (average_m).
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("copied reference_mask.png fallback")
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()