feat(assets): wardrobe wave 1 — per-body shells, 8 garments, try-on UI (T-1089)

Infra: offset-shell gains --per-body mode (each body's own segments, cut/mask
thresholds derived from that body's bone landmarks — reproduces the
hand-calibrated reference constants exactly on average_m); compositor prefers
<body>_mask.png with reference_mask.png fallback; tshirt re-authored per-body
on all 11 (the Q-060 torso poke-through class is GONE — residual flags are a
sleeve-hem epsilon artifact on thick arms, offset-insensitive, documented).

Garments (all per-body x 11, chromakey-gated <=150px worst, previewed):
hoodie (hood-down roll, kangaroo pocket, logo), button-down (collar/placket),
shorts, jeans (analytic denim field driving albedo+mask together; boundary
weld + open-rim flattening — real segment-splitter findings), formal pants,
jacket (over-shirt standoff, zip), suit_jacket_black (lapel region, tintable
shirt triangle — the hand-author proof), uniform_utility (11-segment
coverall, gap-free waist join by construction, 4-zone showcase, logo patch).

Try-on UI: creation screen shows per-region tint pickers (multi_region
garments) + logo picker (logo_capable + logos/*.png scan), data-driven off
manifest+coverage. Manifest merged by the lead: 12 clothing entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:35:20 +02:00
co-authored by Claude Fable 5
parent dd0d220846
commit c765efd54e
345 changed files with 5369 additions and 54 deletions
@@ -0,0 +1,426 @@
"""
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):
tooling/blender --background --python \
tooling/garment-fit/blender_author_buttondown.py -- \
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()
@@ -0,0 +1,659 @@
"""
blender_author_denim_pants.py (T-1089, jeans_modern + denim pants family)
Authors full-length DENIM pants as per-body offset shells, reusing
blender_author_offset_shell.py as a library (scene build, join, offset,
solidify, GLB export). Sibling companions already cover the plain lower-body
family (blender_author_offset_shell_legs.py — shorts, 2-region;
blender_author_lower_shell.py — formal, crease lines); this one adds what
denim needs and they don't provide, as reusable parameters:
* TEXEL-level feature painting: every UV0 triangle is rasterized with
barycentric-interpolated 3D positions, and an analytic denim feature
field is evaluated per texel. ONE field evaluation drives BOTH outputs,
so the painted albedo and the region mask always agree:
- albedo: painted seam thread lines (outseam/inseam azimuth around the
per-z leg axis, centre-front fly, back yoke V), front pocket
arcs, back pocket outlines, belt loops, waist button,
waist/cuff border stitching — flat toon-friendly, identity
carried by the texture (style pin: modern only).
- mask: waistband -> R, legs -> G, seams + cuff band -> B
(spec: waistband=R, legs=G, seams/cuffs=B). Seam LINES are
in the B channel, not just the cuff band, so contrast-thread
recoloring works (toon_garment.gdshader channel-blends).
* boundary WELD before offsetting — the waist join (hips<->leg_upper) and
knee join (leg_upper<->leg_lower) carry duplicated boundary-ring verts
per segment; offsetting un-welded rings along diverging normals opens
cracks, so coincident verts are merged first (weights identical by
origin, so skinning is unaffected).
* --hem-frac: fraction of the hip->ankle span covered, measured up from the
ankle (1.0 = full length to the ankle = jeans; lower values give cropped
variants; cut rims are capped by Solidify(use_rim) like the base sleeves).
* a parked logo_uv TEXCOORD_1 layer (all UVs at (2,2)): pants are not
logo-capable, but toon_garment.gdshader samples UV2 unconditionally, so
every multi_region garment ships a well-defined logo channel.
* open-rim FLATTENING: the segment splitter cuts along weight thresholds,
so the waist and ankle boundary rings are jagged "teeth" (~5-6 cm deep on
average_m); boundary verts are pulled onto clean planes (waist ring down
to its own valley, ankle rings onto the ankle-joint plane) pre-offset.
* --waist-flare: extra feathered radial stand-off at the waistband rim —
deep-crouch waist-fold clip mitigation (QA evidence, peasant + jeans).
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r. Natural
boundaries give the waist opening (seg_hips top rim) and the ankle hems
(seg_leg_lower bottom) for free.
All cut/mask/paint parameters derive PER BODY from that body's own bone
landmarks (thigh_l/calf_l line) and measured mesh extents (waist rim, ankle
rim, crotch = seg_hips lowest ring), scaled by the body's garment span and
hip half-width — the same proportional-ratio philosophy as
base.derive_thresholds, so the denim details stay consistent across all 11
bodies. Per-body mode only (offset shells author per body, Q-060).
Usage (jeans_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_denim_pants.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/jeans_modern \
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 1.0] \
[--waist-flare 0.007] [--base-rgb 0.24,0.32,0.45] [--plain]
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 (clothing pre-fitted per body type), D-251 (in-house
wardrobe), Q-060 (per-body offset shells).
"""
import importlib.util
import os
import shutil
import sys
import bmesh
import bpy
import numpy as np
# --------------------------------------------------------------------------
# Import the base offset-shell module (shared 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
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
# --------------------------------------------------------------------------
# Parameters
# --------------------------------------------------------------------------
COVERED_SEGMENTS = [
"seg_hips",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
HEM_FRAC = 1.0 # 1.0 = full leg to the ankle (jeans)
WELD_DIST = 5e-4 # boundary-ring weld tolerance (0.5 mm)
TEX_SIZE = 1024 # albedo + mask resolution (painted seams need >512)
WAIST_FLARE_M = 0.007 # extra radial stand-off at the waistband rim
# (deep-crouch waist-fold mitigation, feathered)
# Vertical proportions — fractions of the garment span (waist_z - hem_z).
BAND_FRAC = 0.055 # waistband height (R region)
CUFF_FRAC = 0.032 # cuff band height (B region)
SEAM_W_FRAC = 0.0095 # painted seam line width
YOKE_DROP_FRAC = 0.055 # back yoke centre depth below the waistband
YOKE_RISE_FRAC = 0.030 # yoke V rise toward the sides
BPOCKET_TOP_FRAC = 0.075 # back pocket top below the waistband
BPOCKET_HH_FRAC = 0.048 # back pocket half-height
FPOCKET_RZ_FRAC = 0.14 # front pocket arc vertical radius
# Horizontal proportions — fractions of the hip half-width (|thigh head x|).
BPOCKET_CX_FRAC = 0.80 # back pocket centre
BPOCKET_HW_FRAC = 0.52 # back pocket half-width
FPOCKET_CX_FRAC = 1.30 # front pocket arc centre (near the outseam corner)
FPOCKET_RX_FRAC = 0.75 # front pocket arc horizontal radius
LOOP_X_FRACS = (0.55, 1.30) # belt-loop |x| positions (front + back pairs)
LOOP_W_M = 0.016 # belt loop width (m, scaled by hip width)
# Denim style (sRGB floats; saved as-is — matches the proven base pipeline).
DENIM_RGB = (0.240, 0.320, 0.450) # indigo denim
THREAD_RGB = (0.800, 0.620, 0.340) # contrast stitching thread
BUTTON_RGB = (0.850, 0.720, 0.480) # waist button metal
ALBEDO_NOISE = 0.020 # +/- woven jitter
CUFF_SHADE = 0.90 # cuff band albedo darkening
LOOP_SHADE = 0.80 # belt-loop albedo darkening
PLAIN = False # --plain: skip thread/pocket/button paint
NOISE_SEED = 2089
# Reference proportions (average_m) the fractions were calibrated against.
_REF_SPAN = 1.007 # waist rim z (1.093) - ankle z (0.086)
_REF_HIP_X = 0.0906 # |thigh_l head x|
# --------------------------------------------------------------------------
# Per-body landmarks
# --------------------------------------------------------------------------
class LegLandmarks:
"""Cut/mask/paint parameters derived from one body's bones + mesh."""
def __init__(self, armature, waist_z, ankle_z, crotch_z):
bones = armature.data.bones
thigh = bones.get("thigh_l")
calf = bones.get("calf_l")
if thigh is None or calf is None:
raise RuntimeError("thigh_l/calf_l missing — not the 65-bone rig?")
self.waist_z = waist_z
self.ankle_z = ankle_z
self.crotch_z = crotch_z
self.hip_x = abs(thigh.head_local.x)
# Leg axis control points (z-increasing) for azimuth seam placement.
# x values are the +x (left) leg; the right leg mirrors via sign.
self.leg_z_pts = np.array(
[calf.tail_local.z, calf.head_local.z, thigh.head_local.z])
self.leg_x_pts = np.array(
[abs(calf.tail_local.x), abs(calf.head_local.x),
abs(thigh.head_local.x)])
self.leg_y_pts = np.array(
[calf.tail_local.y, calf.head_local.y, thigh.head_local.y])
self.hem_z = ankle_z # finalized after the hem cut
def finalize(self, hem_z):
self.hem_z = hem_z
self.span = self.waist_z - self.hem_z
self.sh = self.hip_x / _REF_HIP_X
self.band_h = BAND_FRAC * self.span
self.cuff_h = CUFF_FRAC * self.span
self.seam_w = SEAM_W_FRAC * self.span
self.band_z = self.waist_z - self.band_h
self.cuff_top = self.hem_z + self.cuff_h
log(f"landmarks: waist={self.waist_z:.3f} hem={self.hem_z:.3f} "
f"crotch={self.crotch_z:.3f} hip_x={self.hip_x:.3f} "
f"band_z={self.band_z:.3f} cuff_top={self.cuff_top:.3f} "
f"seam_w={self.seam_w * 1000:.1f}mm")
def probe_hips_bounds(body_dir):
"""Import seg_hips alone to measure the crotch (its lowest ring) exactly."""
base.clear_scene()
objs = base.import_glb(os.path.join(body_dir, "seg_hips.glb"))
zs = []
for o in objs:
if base.is_body_mesh(o):
zs.extend(v.co.z for v in o.data.vertices)
if not zs:
raise RuntimeError("seg_hips.glb yielded no skinned mesh")
return min(zs), max(zs)
# --------------------------------------------------------------------------
# Geometry: weld + hem cut
# --------------------------------------------------------------------------
def weld_boundaries(shell):
"""Merge coincident segment-boundary verts so the offset can't open cracks."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_DIST)
merged = before - len(bm.verts)
bm.to_mesh(me)
bm.free()
me.update()
log(f"welded segment boundaries: {merged} verts merged "
f"({before} -> {len(me.vertices)})")
def flatten_open_rims(shell, ankle_plane):
"""Pull the open boundary rings onto clean planes (pre-offset).
The body segment splitter cuts along weight thresholds, not edge loops, so
the seg_hips top edge and the seg_leg_lower ankle edges are jagged rings
of "teeth" (~5-6 cm on average_m). On the skin this hides under the
neighbouring segment; on a garment shell it becomes a ragged silhouette.
The top ring's verts are pulled DOWN to the ring's own valley (deepest
notch) — a clean straight waist edge without inventing coverage. The
bottom rings' verts are moved onto `ankle_plane` (the ankle joint for
full-length pants, or the hem-cut plane for cropped variants) — a clean
straight hem AT the ankle. Only boundary verts move; interior verts are
untouched, and weights/UVs ride along, so skinning and painting are
unaffected. Returns (waist_plane, ankle_plane).
"""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
boundary = set()
for e in bm.edges:
if len(e.link_faces) == 1: # open boundary
boundary.update(v.index for v in e.verts)
zs = [bm.verts[i].co.z for i in boundary]
z_mid = (min(zs) + max(zs)) / 2.0
top = [i for i in boundary if bm.verts[i].co.z > z_mid]
bottom = [i for i in boundary if bm.verts[i].co.z <= z_mid]
waist_plane = min(bm.verts[i].co.z for i in top)
teeth_top = max(bm.verts[i].co.z for i in top) - waist_plane
teeth_bot_lo = min(bm.verts[i].co.z for i in bottom)
teeth_bot_hi = max(bm.verts[i].co.z for i in bottom)
for i in top:
bm.verts[i].co.z = waist_plane
for i in bottom:
bm.verts[i].co.z = ankle_plane
bm.to_mesh(me)
bm.free()
me.update()
log(f"flattened open rims: waist ring ({len(top)} verts, teeth "
f"{teeth_top:.3f} m) -> {waist_plane:.3f}; ankle rings "
f"({len(bottom)} verts, z {teeth_bot_lo:.3f}..{teeth_bot_hi:.3f}) "
f"-> {ankle_plane:.3f}")
return waist_plane, ankle_plane
def waist_flare(shell, waist_plane, band_h, flare):
"""Extra RADIAL stand-off at the waistband rim, feathered over 2x the band
height (pre-solidify). QA evidence: in deep crouch the lower-back skin
folds over the waist rim (the peasant set's known 'deep-crouch waist gap',
worst on small bodies). Flaring the band outward gives the fold room and
reads as a natural jeans waistband stand-off."""
if flare <= 0.0:
return
z0 = waist_plane - 2.0 * band_h
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.normal_update()
n = 0
for v in bm.verts:
if v.co.z > z0:
t = min((v.co.z - z0) / (2.0 * band_h), 1.0)
nx, ny = v.normal.x, v.normal.y
mag = (nx * nx + ny * ny) ** 0.5
if mag > 1e-6:
v.co.x += flare * t * nx / mag
v.co.y += flare * t * ny / mag
n += 1
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"waist flare: {n} verts, +{flare * 1000:.1f} mm radial at rim "
f"(feathered from z={z0:.3f})")
def clamp_waist_residue(shell, waist_plane):
"""Post-solidify safety clamp: any interior tooth verts still above the
waist plane (multi-triangle teeth) get squashed onto it."""
me = shell.data
n = 0
for v in me.vertices:
if v.co.z > waist_plane:
v.co.z = waist_plane
n += 1
me.update()
if n:
log(f"clamped {n} residual waist verts -> {waist_plane:.3f}")
def hem_cut(shell, lm, hem_frac):
"""Trim the legs below the hem plane. 1.0 keeps the full ankle length."""
if hem_frac >= 0.999:
return lm.ankle_z
hip_z = float(lm.leg_z_pts[-1])
hem_z = lm.ankle_z + (1.0 - hem_frac) * (hip_z - lm.ankle_z)
bm = bmesh.new()
bm.from_mesh(shell.data)
doomed = [v for v in bm.verts if v.co.z < 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={hem_z:.3f}: removed {len(doomed)} verts")
return hem_z
# --------------------------------------------------------------------------
# Denim feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _seam_field(px, py, pz, lm):
"""Evaluate denim features at texel 3D positions (numpy arrays).
Returns bool arrays (seams, thread, in_cuff, in_band, front):
`seams` feeds the mask B channel; `thread` is every painted stitch line.
"""
w2 = lm.seam_w * 0.5
front = py * FRONT_Y_SIGN > 0.004
backside = py * FRONT_Y_SIGN < -0.004
in_band = pz >= lm.band_z
in_cuff = pz <= lm.cuff_top
mid = (~in_band) & (~in_cuff)
# Per-z leg axis, mirrored by x sign.
side = np.where(px >= 0.0, 1.0, -1.0)
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
dx = px - cx
dy = py - cy
r = np.hypot(dx, dy) + 1e-9
# Outseam: azimuth toward the outer (+/-x) direction; constant metric
# width via arc distance. Inseam: inner direction, below the crotch only.
arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
outseam = mid & (arc_out < w2)
arc_in = np.arccos(np.clip(-dx * side / r, -1.0, 1.0)) * r
inseam = mid & (arc_in < w2) & (pz < lm.crotch_z - 0.01 * lm.span / _REF_SPAN)
# Centre-front fly stitch (slightly off-centre, classic J-front).
fly = front & mid & (np.abs(px - 0.012 * lm.sh) < w2) \
& (pz > lm.crotch_z + 0.015 * lm.span / _REF_SPAN)
# Back yoke: shallow V, higher toward the sides.
yoke_z = (lm.band_z - YOKE_DROP_FRAC * lm.span
+ YOKE_RISE_FRAC * lm.span
* np.minimum(np.abs(px) / (1.5 * lm.hip_x), 1.0))
yoke = backside & mid & (np.abs(pz - yoke_z) < w2) \
& (np.abs(px) < 1.6 * lm.hip_x)
seams = outseam | inseam | fly | yoke
# Stitch-only lines (albedo, not mask): waist + cuff border stitching.
wstitch = np.abs(pz - lm.band_z) < w2
cstitch = np.abs(pz - lm.cuff_top) < w2
# Back pocket outlines (rectangle rings on the seat).
bhw = BPOCKET_HW_FRAC * lm.hip_x
bhh = BPOCKET_HH_FRAC * lm.span
bcz = lm.band_z - BPOCKET_TOP_FRAC * lm.span - bhh
adx = np.abs(np.abs(px) - BPOCKET_CX_FRAC * lm.hip_x)
adz = np.abs(pz - bcz)
bpocket = backside & (adx < bhw + w2) & (adz < bhh + w2) \
& ~((adx < bhw - w2) & (adz < bhh - w2))
# Front pocket arcs (quarter-ellipse from waistband toward the outseam).
fcx = FPOCKET_CX_FRAC * lm.hip_x
frx = FPOCKET_RX_FRAC * lm.hip_x
frz = FPOCKET_RZ_FRAC * lm.span
ex = (np.abs(px) - fcx) / frx
ez = (pz - lm.band_z) / frz
fdist = (np.sqrt(ex * ex + ez * ez) - 1.0) * (0.5 * (frx + frz))
fpocket = front & (np.abs(fdist) < w2) & (pz <= lm.band_z) \
& (np.abs(px) <= fcx)
thread = seams | wstitch | cstitch | bpocket | fpocket
return seams, thread, in_cuff, in_band, front
def _paint_texels(px, py, pz, noise, lm):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
n = px.shape[0]
seams, thread, in_cuff, in_band, front = _seam_field(px, py, pz, lm)
# --- albedo ------------------------------------------------------------
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = DENIM_RGB[c] + noise
alb[:, 3] = 1.0
alb[in_cuff, :3] *= CUFF_SHADE
if not PLAIN:
# Belt loops: darkened denim bands on the waistband.
loop_w = LOOP_W_M * lm.sh
loops = np.zeros(n, dtype=bool)
for fx in LOOP_X_FRACS:
loops |= np.abs(np.abs(px) - fx * lm.hip_x) < loop_w * 0.5
loops |= (~front) & (np.abs(px) < loop_w * 0.5) # centre-back loop
loops &= in_band
alb[loops, :3] *= LOOP_SHADE
alb[thread, 0] = THREAD_RGB[0]
alb[thread, 1] = THREAD_RGB[1]
alb[thread, 2] = THREAD_RGB[2]
# Waist button (front, mid-band).
btn = front & (np.hypot(px, pz - (lm.band_z + 0.5 * lm.band_h))
< 0.009 * lm.sh)
alb[btn, 0] = BUTTON_RGB[0]
alb[btn, 1] = BUTTON_RGB[1]
alb[btn, 2] = BUTTON_RGB[2]
# --- region mask: waistband R / legs G / seams+cuffs B ------------------
mask = np.zeros((n, 4), dtype=np.float32)
is_b = (in_cuff | seams) & ~in_band
is_g = ~(in_band | is_b)
mask[in_band, 0] = 1.0
mask[is_b, 2] = 1.0
mask[is_g, 1] = 1.0
return alb, mask
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
evaluate the denim field, write albedo + mask together."""
a, b, c = uvs
A, B, C = cos
ax, ay = a.x * (W - 1), a.y * (H - 1)
bx, by = b.x * (W - 1), b.y * (H - 1)
cx, cy = c.x * (W - 1), 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]
pxg = xs + 0.5
pyg = ys + 0.5
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
w2 = 1.0 - w0 - w1
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
if not inside.any():
return
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
px3 = w0i * A.x + w1i * B.x + w2i * C.x
py3 = w0i * A.y + w1i * B.y + w2i * C.y
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
ysin = ys[inside]
xsin = xs[inside]
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
alb_buf[ysin, xsin] = alb
mask_buf[ysin, xsin] = mask
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
"""Rasterize all UV0 triangles once, producing the painted albedo and the
region mask from one shared feature-field evaluation per texel."""
W = H = TEX_SIZE
rng = np.random.default_rng(NOISE_SEED)
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
* 2.0 * ALBEDO_NOISE)
alb_buf = np.empty((H, W, 4), dtype=np.float32)
for c in range(3):
alb_buf[:, :, c] = DENIM_RGB[c] + noise_buf
alb_buf[:, :, 3] = 1.0
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
mask_buf[:, :, 1] = 1.0 # background = legs green (bleed-safe)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
if not len(bm.loops.layers.uv):
raise RuntimeError("no UV layer for albedo/mask paint")
uv_layer = bm.loops.layers.uv[0]
tri_count = 0
for face in bm.faces:
loops = face.loops[:]
uvs = [loop[uv_layer].uv.copy() for loop in loops]
cos = [loop.vert.co.copy() for loop in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_paint(
alb_buf, mask_buf, noise_buf,
(uvs[0], uvs[i], uvs[i + 1]),
(cos[0], cos[i], cos[i + 1]),
lm, W, H)
tri_count += 1
bm.free()
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
def _save(buf, name, path):
img = bpy.data.images.new(name, W, H, alpha=True)
img.pixels.foreach_set(buf.reshape(-1))
img.update()
img.filepath_raw = path
img.file_format = 'PNG'
img.save()
return img
albedo_img = _save(alb_buf, f"denim_albedo_{body}", albedo_path)
_save(mask_buf, f"denim_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
return albedo_img
# --------------------------------------------------------------------------
# Parked logo UV2 (pants are not logo-capable; the shader still samples UV2)
# --------------------------------------------------------------------------
def author_parked_uv2(shell):
me = shell.data
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
me.uv_layers.new(name="logo_uv")
me.uv_layers.active = me.uv_layers[0]
bm = bmesh.new()
bm.from_mesh(me)
uvl = bm.loops.layers.uv.get("logo_uv")
for face in bm.faces:
for loop in face.loops:
loop[uvl].uv = (2.0, 2.0)
bm.to_mesh(me)
bm.free()
me.update()
log("logo UV2 authored fully parked (not logo-capable)")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_denim_shell(body_dir, out_dir, body, offset, hem_frac):
crotch_z, _hips_top = probe_hips_bounds(body_dir)
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
weld_boundaries(shell)
zs = [v.co.z for v in shell.data.vertices]
waist_z, ankle_z = max(zs), min(zs)
lm = LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
hem_z = hem_cut(shell, lm, hem_frac)
# Full-length pants hem AT the ankle joint (calf tail); cropped variants
# hem at the cut plane.
ankle_plane = float(lm.leg_z_pts[0]) if hem_frac >= 0.999 else hem_z
waist_plane, ankle_plane = flatten_open_rims(shell, ankle_plane)
base.offset_outward(shell, offset)
waist_flare(shell, waist_plane,
BAND_FRAC * (waist_plane - ankle_plane), WAIST_FLARE_M)
base.solidify(shell, base.CLOTH_THICKNESS_M)
clamp_waist_residue(shell, waist_plane)
# Landmarks reference the CLEAN rims (band under the flattened waist edge,
# cuff above the flattened hem).
lm.waist_z = waist_plane
lm.finalize(ankle_plane)
author_parked_uv2(shell)
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
mask_path = os.path.join(out_dir, f"{body}_mask.png")
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
base.assign_fabric_material(shell, albedo_img)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
def main():
global HEM_FRAC, DENIM_RGB, PLAIN, WAIST_FLARE_M
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-frac F] [--waist-flare M] "
"[--base-rgb r,g,b] [--plain]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = base.PER_BODY_OFFSET_M
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--hem-frac" in argv:
HEM_FRAC = float(argv[argv.index("--hem-frac") + 1])
if "--waist-flare" in argv:
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
if "--base-rgb" in argv:
DENIM_RGB = tuple(
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
if "--plain" in argv:
PLAIN = True
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"denim per-body mode: {len(bodies)} bodies, offset "
f"{offset * 1000:.0f} mm, hem-frac {HEM_FRAC}, plain={PLAIN}")
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_denim_shell(body_dir, out_dir, body, offset, HEM_FRAC)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
ref = base.REFERENCE_BODY
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
if os.path.isfile(ref_mask):
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
if os.path.isfile(ref_alb):
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
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()
@@ -0,0 +1,590 @@
"""
blender_author_hoodie.py (T-1089 — hoodie_modern, per-body offset-shell)
Hooded-top companion to blender_author_offset_shell.py: reuses the base
module's segment join / bone-ratio thresholds / offset / solidify / logo-UV2 /
export machinery and layers the hoodie-specific geometry + texture identity on
top. Everything spatial is derived from bone landmarks (ratios of neck_01
length, shoulder |x|, spine span) so the same parameters produce a
proportionally identical garment on all 11 bodies. The parameter block below
is the reusable seam for the rest of the hooded/long-sleeve family
(track_jacket, sweater_modern): import this module and override.
Hoodie deltas over the base t-shirt shell:
* covers torso + torso_upper + FULL arms (long sleeves, wrist-cut on the
lowerarm bone instead of the upperarm short-sleeve cut)
* HOOD DOWN — a rolled collar bulk ring: collar-band verts are inflated
radially away from the neck axis (back-biased, slight upward lift) before
solidify, so the roll gets real thickness and caps into a ring
* looser standoff (16 mm vs the 12 mm per-body tee) + thicker cloth (6 mm)
* painted albedo identity (texture-carried, per body because UV0 face
classification is per body): kangaroo pocket fill + stitch outline,
drawstrings, ribbed hem + cuff bands — all painted as LUMINANCE detail so
the luma-preserving toon_garment recolor keeps them under any tint
* region mask: R = collar/hood roll (asymmetric drop — drapes lower on the
back), G = body, B = sleeves + kangaroo pocket
* logo-capable chest via the base UV2 channel (unchanged)
PER-BODY ONLY (Q-060: offset-shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_hoodie.py -- \
client/assets/characters/bodies client/assets/characters/clothing/hoodie_modern \
[--bodies a,b,c] [--offset M]
Writes per body: <out_dir>/<body>.glb, <body>_mask.png, <body>_base_albedo.png
Plus fallbacks: <out_dir>/base_albedo.png + reference_mask.png (= average_m's).
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
"""
import importlib.util
import math
import os
import shutil
import sys
import bpy
import bmesh
import numpy as np
from mathutils import Vector
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load_base():
spec = importlib.util.spec_from_file_location(
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
base = _load_base()
# --------------------------------------------------------------------------
# Hoodie parameters (the reusable seam — override for track_jacket/sweater)
# --------------------------------------------------------------------------
GARMENT_ID = "hoodie_modern"
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.016 # loose standoff (per-body construction guarantees it)
THICKNESS_M = 0.006 # fleece-weight cloth
WRIST_FRAC = 0.92 # fraction of the lowerarm kept (sleeve ends at wrist)
# Hood-down roll: fractions of neck_01 length unless noted.
ROLL_OUT_FRAC = 0.40 # radial bulge magnitude
ROLL_LIFT_FRAC = 0.14 # upward lift at the rim
ROLL_Z_START_FRAC = 0.45 # roll influence starts this far below collar_z_min
ROLL_REACH_COLLAR_X = 1.7 # candidate radius around the neck axis (x collar_x_abs)
ROLL_FRONT_GAIN = 0.55 # bulge scale at the front...
ROLL_BACK_GAIN = 1.10 # ...and at the back (hood mass hangs behind)
ROLL_EXPONENT = 1.7 # falloff sharpness toward the rim
# Region mask R (roll) band: drop below collar_z_min, x neck_len, per side.
R_Z_DROP_FRONT = 0.15
R_Z_DROP_BACK = 0.55
# Kangaroo pocket: z as fractions of the waistband-top -> chest_z_lo span
# (sits ABOVE the ribbed hem band), x as fractions of shoulder |x|.
POCKET_Z0_FRAC = 0.06
POCKET_Z1_FRAC = 0.88
POCKET_WB_FRAC = 0.56 # bottom half-width
POCKET_WT_FRAC = 0.30 # top half-width (diagonal hand openings)
POCKET_FILL_MULT = 0.95 # subtle patch shading
POCKET_LINE_MULT = 0.70 # stitch outline darkening
POCKET_LINE_PX = 2 # outline thickness (erosion iterations)
# Drawstrings (front only): x offset / half-width as fractions of collar_x_abs,
# z as fractions of neck_len.
STRING_X_FRAC = 0.30
STRING_HALF_W_M = 0.005
STRING_TOP_DROP_FRAC = 0.10
STRING_LEN_FRAC = 0.75
STRING_MULT = 0.50
# Ribbed bands: hem (x spine span) and cuffs (x lowerarm length). The cuff is
# anchored to the mesh's ACTUAL post-cut reach, not the nominal wrist plane —
# the vert-threshold cut leaves a jagged face boundary that stops 1-3 cm short
# of the plane, so a nominal-anchored band would mostly land on deleted faces.
HEM_BAND_FRAC = 0.08
CUFF_LEN_FRAC = 0.16
BAND_MULT = 0.85
BAND_LINE_MULT = 0.72
BAND_LINE_M = 0.004
# Muted street-tone fabric (luma drives the toon_garment recolor).
FABRIC_RGB = (0.55, 0.56, 0.58)
FABRIC_NOISE = 0.035
ALBEDO_SEED = 1091
def log(msg):
print(f"[hoodie] {msg}")
# --------------------------------------------------------------------------
# Landmarks beyond the base thresholds
# --------------------------------------------------------------------------
def derive_landmarks(armature, thr):
"""Hoodie-specific bone landmarks (all in body-local metres)."""
bones = armature.data.bones
neck = bones.get("neck_01")
la_l = bones.get("lowerarm_l")
la_r = bones.get("lowerarm_r")
if not all([neck, la_l, la_r]):
raise RuntimeError("landmark bones missing (neck_01 / lowerarm_l/r)")
neck_len = neck.tail_local.z - neck.head_local.z
lm = {
"neck_y": neck.head_local.y,
"neck_len": neck_len,
"roll_out": ROLL_OUT_FRAC * neck_len,
"roll_lift": ROLL_LIFT_FRAC * neck_len,
"roll_reach": ROLL_REACH_COLLAR_X * thr["collar_x_abs"],
# wrist cut thresholds per side: (sign, x threshold)
"wrist_cuts": [],
"lowerarm_len": 0.0,
}
for b, sign in [(la_l, +1), (la_r, -1)]:
head_x, tail_x = b.head_local.x, b.tail_local.x
thr_x = head_x + WRIST_FRAC * (tail_x - head_x)
lm["wrist_cuts"].append((sign, thr_x))
lm["lowerarm_len"] = abs(tail_x - head_x)
log(f"landmarks: neck_len {neck_len:.4f} roll_out {lm['roll_out']*1000:.0f}mm "
f"reach {lm['roll_reach']:.3f} wrist cuts "
+ " ".join(f"{s:+d}@{t:.3f}" for s, t in lm["wrist_cuts"]))
return lm
# --------------------------------------------------------------------------
# Geometry: wrist cut + hood roll
# --------------------------------------------------------------------------
def wrist_cut(shell, lm):
"""Trim the sleeve tubes at the wrist plane (same pattern as base.sleeve_cut,
but on the lowerarm bone so the sleeves stay full length)."""
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 lm["wrist_cuts"]:
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")
def hood_roll(shell, thr, lm):
"""Inflate collar-band verts radially away from the neck axis to read as a
rolled-down hood: back-biased bulge + slight rim lift. Runs after the
outward offset and before solidify (the roll then gets cloth thickness)."""
z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * lm["neck_len"]
reach = lm["roll_reach"]
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
candidates = []
z_rim = z_start
for v in bm.verts:
if v.co.z < z_start:
continue
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
if hd > reach or hd < 1e-6:
continue
candidates.append((v, hd))
z_rim = max(z_rim, v.co.z)
if z_rim <= z_start or not candidates:
log("WARNING: no hood-roll candidates found — roll skipped")
bm.free()
return
moved = 0
for v, hd in candidates:
t = (v.co.z - z_start) / (z_rim - z_start)
w = max(0.0, min(1.0, t)) ** ROLL_EXPONENT
if w <= 0.0:
continue
dir_h = Vector((v.co.x, v.co.y - lm["neck_y"], 0.0)) / hd
backness = 0.5 * (1.0 + dir_h.y * -base.FRONT_Y_SIGN) # +Y = back
gain = ROLL_FRONT_GAIN + (ROLL_BACK_GAIN - ROLL_FRONT_GAIN) * backness
v.co += dir_h * (lm["roll_out"] * w * gain)
v.co.z += lm["roll_lift"] * w
moved += 1
bm.to_mesh(me)
bm.free()
me.update()
log(f"hood roll: {moved} verts inflated (rim z {z_rim:.3f}, "
f"start z {z_start:.3f})")
# --------------------------------------------------------------------------
# Paint maps: rasterize body-space (x, z) into UV0 texel space
# --------------------------------------------------------------------------
def _raster_tri_attr(maps, a, b, c, xs, zs, is_front, is_back, W, H):
"""Barycentric rasterization interpolating body-space x/z per texel."""
ax, ay = a.x * (W - 1), a.y * (H - 1)
bx, by = b.x * (W - 1), b.y * (H - 1)
cx, cy = c.x * (W - 1), 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, px_x = np.mgrid[miny:maxy + 1, minx:maxx + 1]
px = px_x + 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
x_val = w0 * xs[0] + w1 * xs[1] + w2 * xs[2]
z_val = w0 * zs[0] + w1 * zs[1] + w2 * zs[2]
sl = (slice(miny, maxy + 1), slice(minx, maxx + 1))
maps["X"][sl][inside] = x_val[inside]
maps["Z"][sl][inside] = z_val[inside]
maps["valid"][sl][inside] = True
if is_front:
maps["front"][sl][inside] = True
if is_back:
maps["back"][sl][inside] = True
def bake_paint_maps(shell, W, H):
"""Rasterize the shell's UV0 layout into per-texel body-space X/Z maps,
with front/back facing flags (front = -Y on this rig)."""
maps = {
"X": np.zeros((H, W), dtype=np.float32),
"Z": np.zeros((H, W), dtype=np.float32),
"valid": np.zeros((H, W), dtype=bool),
"front": np.zeros((H, W), dtype=bool),
"back": np.zeros((H, W), dtype=bool),
}
bm = bmesh.new()
bm.from_mesh(shell.data)
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 paint-map bake")
for face in bm.faces:
n_front = face.normal.y * base.FRONT_Y_SIGN
c_front = face.calc_center_median().y * base.FRONT_Y_SIGN
is_front = n_front > 0.15 and c_front > 0.0
is_back = n_front < -0.15 and c_front < 0.0
loops = face.loops[:]
uvs = [lp[uv_layer].uv.copy() for lp in loops]
cos = [lp.vert.co for lp in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_attr(
maps, uvs[0], uvs[i], uvs[i + 1],
(cos[0].x, cos[i].x, cos[i + 1].x),
(cos[0].z, cos[i].z, cos[i + 1].z),
is_front, is_back, W, H)
bm.free()
overlap = int((maps["front"] & maps["back"]).sum())
log(f"paint maps: {int(maps['valid'].sum())} texels "
f"(front {int(maps['front'].sum())}, back {int(maps['back'].sum())}, "
f"front/back UV overlap {overlap})")
dump = os.environ.get("HOODIE_DEBUG_MAPS", "")
if dump:
np.savez_compressed(dump, **maps)
log(f"debug maps dumped -> {dump}")
return maps
# --------------------------------------------------------------------------
# Painted albedo (texture-carried modern identity)
# --------------------------------------------------------------------------
def derive_paint_params(shell, thr, lm):
"""Body-space paint geometry, derived from thresholds + final shell mesh."""
sleeve_x = thr["sleeve_x_abs"]
hem_z = min((v.co.z for v in shell.data.vertices
if abs(v.co.x) < sleeve_x), default=1.0)
# Actual sleeve reach from the final mesh (jagged post-cut boundary).
wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=sleeve_x)
chest_lo = thr["chest_z"][0]
hem_band = HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
hem_top = hem_z + hem_band
span = chest_lo - hem_top
shoulder_x = thr["chest_x"][1] / base.CHEST_X_FRAC # invert base ratio
p = {
"hem_z": hem_z,
"hem_band": hem_band,
"pocket_z0": hem_top + POCKET_Z0_FRAC * span,
"pocket_z1": hem_top + POCKET_Z1_FRAC * span,
"pocket_wb": POCKET_WB_FRAC * shoulder_x,
"pocket_wt": POCKET_WT_FRAC * shoulder_x,
"string_x": STRING_X_FRAC * thr["collar_x_abs"],
"string_z_top": thr["collar_z_min"] - STRING_TOP_DROP_FRAC * lm["neck_len"],
"string_len": STRING_LEN_FRAC * lm["neck_len"],
"cuff_len": CUFF_LEN_FRAC * lm["lowerarm_len"],
"wrist_x": wrist_x,
"sleeve_x": sleeve_x,
}
log(f"paint params: hem {hem_z:.3f} pocket z ({p['pocket_z0']:.3f},"
f"{p['pocket_z1']:.3f}) w ({p['pocket_wt']:.3f}->{p['pocket_wb']:.3f})")
return p
def _erode(m, iters):
for _ in range(iters):
m = (m
& np.roll(m, 1, 0) & np.roll(m, -1, 0)
& np.roll(m, 1, 1) & np.roll(m, -1, 1))
return m
def pocket_mask(maps, p):
"""Kangaroo-pocket texel mask: front-only trapezoid with diagonal sides."""
X, Z = maps["X"], maps["Z"]
paintable = maps["front"] & ~maps["back"]
z0, z1 = p["pocket_z0"], p["pocket_z1"]
if z1 <= z0:
return np.zeros_like(paintable)
t = np.clip((z1 - Z) / (z1 - z0), 0.0, 1.0) # 1 at bottom, 0 at top
half_w = p["pocket_wt"] + (p["pocket_wb"] - p["pocket_wt"]) * t
inside = paintable & (Z >= z0) & (Z <= z1) & (np.abs(X) <= half_w)
log(f"pocket mask: {int(inside.sum())} texels")
return inside
def make_painted_albedo(maps, pocket_px, p, out_dir, body):
"""Flat street-tone fabric + painted luminance detail; returns bpy image."""
W = H = base.ALBEDO_SIZE
rng = np.random.default_rng(ALBEDO_SEED)
fabric = np.array(FABRIC_RGB, dtype=np.float32)
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE
rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
X, Z = maps["X"], maps["Z"]
valid = maps["valid"]
front_only = maps["front"] & ~maps["back"]
# Ribbed hem band (all around) + top stitch line.
hem_top = p["hem_z"] + p["hem_band"]
band = valid & (Z <= hem_top) & (np.abs(X) <= p["sleeve_x"])
rgb[band] *= BAND_MULT
line = valid & (np.abs(Z - hem_top) <= BAND_LINE_M) & (np.abs(X) <= p["sleeve_x"])
rgb[line] *= BAND_LINE_MULT
# Ribbed cuffs (all around) + inner stitch line.
cuff_x0 = p["wrist_x"] - p["cuff_len"]
cuff = valid & (np.abs(X) >= cuff_x0)
rgb[cuff] *= BAND_MULT
cline = valid & (np.abs(np.abs(X) - cuff_x0) <= BAND_LINE_M)
rgb[cline] *= BAND_LINE_MULT
log(f"paint counts: hem {int(band.sum())} hemline {int(line.sum())} "
f"cuff {int(cuff.sum())} cuffline {int(cline.sum())} "
f"(hem_top {hem_top:.3f}, cuff_x0 {cuff_x0:.3f})")
zs = Z[valid]
xs_v = np.abs(X[valid])
log(f"map ranges: Z [{zs.min():.3f},{zs.max():.3f}] "
f"|X| [{xs_v.min():.3f},{xs_v.max():.3f}] "
f"Z<=hem_top {int((zs <= hem_top).sum())} "
f"|X|>=cuff_x0 {int((xs_v >= cuff_x0).sum())}")
# Kangaroo pocket: subtle fill + dark stitch outline.
rgb[pocket_px] *= POCKET_FILL_MULT
outline = pocket_px & ~_erode(pocket_px, POCKET_LINE_PX)
rgb[outline] *= POCKET_LINE_MULT
# Drawstrings (front only, hanging from the collar).
z_top = p["string_z_top"]
z_bot = z_top - p["string_len"]
for sx in (+p["string_x"], -p["string_x"]):
s = front_only & (np.abs(X - sx) <= STRING_HALF_W_M) \
& (Z >= z_bot) & (Z <= z_top)
rgb[s] *= STRING_MULT
rgba = np.concatenate([rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
img = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False)
img.pixels.foreach_set(rgba.reshape(-1))
img.update()
sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
img.filepath_raw = sidecar
img.file_format = 'PNG'
img.save()
log(f"painted albedo -> {sidecar}")
# The glTF exporter names the embedded image after the file basename, and
# the Godot import EXTRACTS it as <glb>_<imagename>.png. Point the image at
# the shared base_albedo.png so the extraction lands exactly on the sidecar
# name above (<body>_base_albedo.png, same pixels — tshirt convention),
# instead of a doubled <body>_<body>_base_albedo.png. The shared file is
# re-pointed to the reference body's paint at the end of the run.
img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
img.save()
return img
# --------------------------------------------------------------------------
# Region mask: R = hood roll, G = body, B = sleeves + pocket
# --------------------------------------------------------------------------
def _classify_hoodie(center, thr, lm):
x, y, z = center.x, center.y, center.z
if abs(x) >= thr["sleeve_x_abs"]:
return (0.0, 0.0, 1.0, 0.0) # sleeves -> B
hd = math.hypot(x, y - lm["neck_y"])
is_front = y * base.FRONT_Y_SIGN > 0.0
drop = R_Z_DROP_FRONT if is_front else R_Z_DROP_BACK
z_min = thr["collar_z_min"] - drop * lm["neck_len"]
if z >= z_min and hd <= lm["roll_reach"] + lm["roll_out"] + 0.01:
return (1.0, 0.0, 0.0, 0.0) # hood roll -> R
return (0.0, 1.0, 0.0, 0.0) # body -> G
def bake_hoodie_mask(shell, out_path, thr, lm, pocket_px):
"""Per-face region rasterization (like base.bake_region_mask) with the
hoodie classifier, then the pocket texels overlaid into B."""
W = H = base.MASK_SIZE
buf = np.zeros((H, W, 4), dtype=np.float32)
buf[:, :, 1] = 1.0 # body-green background (bleed safety)
bm = bmesh.new()
bm.from_mesh(shell.data)
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 = {"roll": 0, "body": 0, "sleeve": 0}
for face in bm.faces:
color = _classify_hoodie(face.calc_center_median(), thr, lm)
if color[0] > 0.5:
counts["roll"] += 1
elif color[2] > 0.5:
counts["sleeve"] += 1
else:
counts["body"] += 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()))
# Pocket joins the sleeve tint region (B), per the spec.
if pocket_px is not None and pocket_px.shape == (H, W):
buf[pocket_px] = (0.0, 0.0, 1.0, 0.0)
img = bpy.data.images.new(f"{GARMENT_ID}_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}")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_hoodie(body_dir, out_dir, body, offset):
base.clear_scene()
shell, armature = base.build_covered_mesh(body_dir)
thr = base.derive_thresholds(armature)
lm = derive_landmarks(armature, thr)
wrist_cut(shell, lm)
base.offset_outward(shell, offset)
hood_roll(shell, thr, lm)
base.solidify(shell, THICKNESS_M)
maps = bake_paint_maps(shell, base.ALBEDO_SIZE, base.ALBEDO_SIZE)
p = derive_paint_params(shell, thr, lm)
pocket_px = pocket_mask(maps, p)
albedo_img = make_painted_albedo(maps, pocket_px, p, out_dir, body)
base.assign_fabric_material(shell, albedo_img)
base.author_logo_uv(shell, thr)
# Mask texels are MASK_SIZE; paint maps are ALBEDO_SIZE. Sizes match (512)
# today; rebake the pocket mask if they ever diverge.
mask_pocket = pocket_px if base.MASK_SIZE == base.ALBEDO_SIZE else None
bake_hoodie_mask(shell, os.path.join(out_dir, f"{body}_mask.png"),
thr, lm, mask_pocket)
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]")
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)
base.COVERED_SEGMENTS = SEGMENTS
log(f"per-body hoodie: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
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_hoodie(body_dir, out_dir, body, offset)
results.append((body, "ok"))
except Exception as exc:
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")
ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
if os.path.isfile(ref_albedo):
shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
log("copied base_albedo.png fallback")
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()
@@ -0,0 +1,529 @@
"""
blender_author_jacket_shell.py (T-1089, route (c) hand-author proof: suit jacket)
Jacket-family companion to blender_author_offset_shell.py — imports that module
as a library (segments -> join -> offset -> solidify -> skinned export are
reused unchanged) and adds what a JACKET needs beyond a t-shirt:
* full-arm coverage (torso + torso_upper + upper AND lower arms) with a
WRIST cut on the lowerarm bone (long sleeves) instead of an upper-arm cut;
* a boundary WELD after the segment join, so the elbow/shoulder segment
seams offset as one continuous surface (no gap rings on long sleeves);
* a jacket standoff (default 18 mm — outerwear drape over the 12 mm tee);
* a PAINTED albedo + region mask baked together in ONE per-pixel
rasterization pass: each texel's 3D body-local position is interpolated
barycentric-ally, so the V-opening / lapels / shirt triangle have crisp
edges independent of the low-poly tessellation (the base script's
per-FACE classification would read chunky on a chest V).
Region-mask convention for the suit (toon_garment.gdshader tint routing):
R = lapels + collar band (tint_0 — subtle satin two-tone)
G = jacket body (tint_1)
B = sleeves (tint_2)
A = shirt triangle in the V (tint_3 — default WHITE so outfits can
recolor the visible shirt)
Painted albedo details (luma-carried; the shader recolors hue per region but
keeps albedo luminance, so detail must live in the 0.53-max luma band or the
`luma*1.5+0.2` curve clips it): white-shirt triangle with placket + buttons,
lapel fold + edge shading, front closure seam + jacket button below the V,
sleeve cuff band. All geometry parameters are bone-landmark RATIOS (same
anchors as the base script: shoulder = upperarm head |x|, neck_01, spine_01,
plus lowerarm for the wrist), so every body derives its own proportional
cut/paint constants — calibrated to the intended metric sizes on average_m.
PER-BODY ONLY: offset-shell garments are authored per body (Q-060 route
guidance) — there is no single-reference mode here.
tooling/blender --background --python \
tooling/garment-fit/blender_author_jacket_shell.py -- \
<bodies_root> <out_dir> \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-frac 0.85]
Writes per body:
<out_dir>/<body>.glb skinned jacket authored on that body
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0)
Plus:
<out_dir>/base_albedo.png copy of average_m's painted albedo
<out_dir>/reference_mask.png copy of average_m's mask (runtime fallback)
The painted per-body albedo is embedded in each GLB (image URI basename
"base_albedo", staged under .cache/garment-fit-suit/<body>/); on import
Godot extracts it to <out_dir>/<body>_base_albedo.png — the same layout the
t-shirt reference garment has.
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
"""
import sys
import os
import shutil
import bpy
import bmesh
import numpy as np
# Import the base offset-shell module as a library.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import blender_author_offset_shell as base # noqa: E402
# --------------------------------------------------------------------------
# Jacket parameters
# --------------------------------------------------------------------------
JACKET_SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
]
JACKET_OFFSET_M = 0.018 # outerwear standoff (tee is 12 mm per-body)
WRIST_FRAC = 0.92 # fraction of lowerarm length kept. NB the vert-delete
# cut also drops faces touching deleted verts, so the
# visible sleeve ends ~1 low-poly ring earlier — 0.92
# lands the hem just above the wrist (matches the
# buttondown's calibrated SLEEVE_END_FRAC).
CUFF_FRAC = 0.14 # last fraction of the KEPT sleeve painted as cuff band
WELD_DIST_M = 0.0001 # merge distance for segment-boundary weld
BAKE_SIZE = 1024 # albedo + mask resolution (512 is too coarse for the
# 6 mm placket / button dots on the torso island)
# --- V-opening / lapel geometry, as ratios of average_m bone landmarks -----
# (anchors identical to base: shoulder |x| 0.1919, neck head z 1.5205,
# spine_01 head z 1.072 -> spine span 0.4485)
V_HALF_TOP_M = 0.068 # V half-width at the collar on average_m
V_BOT_Z_M = 1.17 # V bottom (jacket closure point) on average_m
LAPEL_W_M = 0.045 # lapel band width
LAPEL_DROP_M = 0.020 # lapel wraps slightly below the V point
EDGE_W_M = 0.011 # lapel fold/edge shading line width (albedo only)
PLACKET_HALF_M = 0.006 # shirt placket half-width
CLOSURE_HALF_M = 0.0045 # jacket front closure seam half-width
SHIRT_BTN_R_M = 0.0055 # shirt button radius
JACKET_BTN_R_M = 0.009 # jacket button radius
JACKET_BTN_DROP_M = 0.030 # jacket button sits this far below the V point
SHIRT_BTN_FRACS = (0.11, 0.30) # shirt button z, as frac of (v_top - v_bot)
V_HALF_TOP_FRAC = V_HALF_TOP_M / base._REF_SHOULDER_X
LAPEL_W_FRAC = LAPEL_W_M / base._REF_SHOULDER_X
EDGE_W_FRAC = EDGE_W_M / base._REF_SHOULDER_X
PLACKET_HALF_FRAC = PLACKET_HALF_M / base._REF_SHOULDER_X
CLOSURE_HALF_FRAC = CLOSURE_HALF_M / base._REF_SHOULDER_X
SHIRT_BTN_R_FRAC = SHIRT_BTN_R_M / base._REF_SHOULDER_X
JACKET_BTN_R_FRAC = JACKET_BTN_R_M / base._REF_SHOULDER_X
_REF_SPAN = base._REF_NECK_Z - base._REF_SPINE_LO_Z
V_BOT_FRAC = (V_BOT_Z_M - base._REF_SPINE_LO_Z) / _REF_SPAN
LAPEL_DROP_FRAC = LAPEL_DROP_M / _REF_SPAN
JACKET_BTN_DROP_FRAC = JACKET_BTN_DROP_M / _REF_SPAN
# --- Painted albedo luma palette (neutral hue; shader tints supply color) ---
# Kept inside the luma*1.5+0.2 <= 1.0 budget (luma <= 0.53) so painted detail
# survives a white tint on the shirt region.
LUMA_FABRIC = 0.400 # jacket body + sleeves + back
LUMA_SATIN = 0.475 # lapels + collar band (subtle two-tone vs body)
LUMA_EDGE = 0.250 # lapel fold/edge shading lines
LUMA_SHIRT = 0.520 # shirt triangle (renders ~white under white tint)
LUMA_PLACKET = 0.440 # shirt placket strip
LUMA_SHIRT_BTN = 0.320 # shirt buttons
LUMA_CLOSURE = 0.290 # jacket closure seam below the V
LUMA_JACKET_BTN = 0.180 # jacket button
LUMA_CUFF = 0.330 # sleeve cuff band
ALBEDO_NOISE = 0.018 # +/- woven jitter (4-px blocks — compresses well)
ALBEDO_TINT = (1.0, 1.0, 1.03) # slightly cool cast on the neutral greys
log = base.log
# --------------------------------------------------------------------------
# Suit-specific threshold derivation (extends base.derive_thresholds)
# --------------------------------------------------------------------------
def derive_jacket_thresholds(armature, wrist_frac):
thr = base.derive_thresholds(armature)
bones = armature.data.bones
ua_l = bones.get("upperarm_l")
neck = bones.get("neck_01")
spine01 = bones.get("spine_01")
if ua_l and neck and spine01:
ua_r = bones.get("upperarm_r")
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
spine_lo = spine01.head_local.z
span = neck.head_local.z - spine_lo
else:
log("WARNING: landmark bones missing — legacy average_m jacket constants")
shoulder_x = base._REF_SHOULDER_X
spine_lo = base._REF_SPINE_LO_Z
span = _REF_SPAN
v_top = thr["collar_z_min"]
v_bot = spine_lo + V_BOT_FRAC * span
thr.update({
"v_top_z": v_top,
"v_bot_z": v_bot,
"v_half_top": shoulder_x * V_HALF_TOP_FRAC,
"lapel_w": shoulder_x * LAPEL_W_FRAC,
"lapel_drop": LAPEL_DROP_FRAC * span,
"edge_w": shoulder_x * EDGE_W_FRAC,
"placket_half": shoulder_x * PLACKET_HALF_FRAC,
"closure_half": shoulder_x * CLOSURE_HALF_FRAC,
"shirt_btn_r": shoulder_x * SHIRT_BTN_R_FRAC,
"jacket_btn_r": shoulder_x * JACKET_BTN_R_FRAC,
"jacket_btn_z": v_bot - JACKET_BTN_DROP_FRAC * span,
"shirt_btn_zs": [v_bot + f * (v_top - v_bot) for f in SHIRT_BTN_FRACS],
})
# Wrist cut planes + cuff band start, from the lowerarm bones (arms run
# along +/-X in rest pose, elbow head -> wrist tail).
cut_planes = [] # (sign, wrist_thr_x, cuff_start_x)
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 uncut on that side")
continue
head_x = b.head_local.x
tail_x = b.tail_local.x
wrist_thr = head_x + wrist_frac * (tail_x - head_x)
cuff_start = head_x + wrist_frac * (1.0 - CUFF_FRAC) * (tail_x - head_x)
cut_planes.append((sign, wrist_thr, cuff_start))
log(f"wrist cut {bone_name}: keep |x| to {wrist_thr:.3f} "
f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {cuff_start:.3f}")
thr["wrist_planes"] = cut_planes
log(f"jacket thresholds: V top z {v_top:.3f} bottom z {v_bot:.3f} "
f"half-top {thr['v_half_top']:.3f} lapel {thr['lapel_w']:.3f} "
f"btn z {thr['jacket_btn_z']:.3f}")
return thr
# --------------------------------------------------------------------------
# Geometry: weld + wrist cut
# --------------------------------------------------------------------------
def weld_segment_boundaries(shell):
"""Merge coincident verts along segment seams (elbow/shoulder/chest lines).
Adjacent body segments duplicate their shared boundary loop; unwelded, the
two copies get island-local normals and offset APART, opening a gap ring at
every seam. Welding first makes the shell offset as one surface. Merged
verts come from the same original body vertex, so weights and loop UVs are
identical/preserved.
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST_M)
bm.to_mesh(shell.data)
after = len(shell.data.vertices)
bm.free()
shell.data.update()
log(f"welded segment boundaries: {before} -> {after} verts "
f"({before - after} merged)")
def wrist_cut(shell, thr):
"""Delete sleeve verts beyond the wrist plane on each lower arm."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = []
for v in bm.verts:
for sign, wrist_thr, _cuff in thr["wrist_planes"]:
if sign > 0 and v.co.x > wrist_thr:
to_delete.append(v)
break
if sign < 0 and v.co.x < wrist_thr:
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")
# --------------------------------------------------------------------------
# Combined per-pixel albedo paint + region mask bake
# --------------------------------------------------------------------------
def _classify_pixels(X, Y, Z, face_sleeve, face_side, thr):
"""Vectorized region + luma classification for one triangle's texels.
Returns (mask_rgba[N,4], luma[N]) for interpolated body-local positions.
Region priority: collar > shirt > lapel > body; sleeves are per-face.
"""
n = X.shape[0]
mask = np.zeros((n, 4), dtype=np.float32)
luma = np.full(n, LUMA_FABRIC, dtype=np.float32)
front = (Y * base.FRONT_Y_SIGN) > 0.010
ax = np.abs(X)
if face_sleeve:
mask[:, 2] = 1.0 # B = sleeves
# cuff band on this arm's outer end
for sign, wrist_thr, cuff_start in thr["wrist_planes"]:
if sign != face_side:
continue
in_cuff = (X * sign) >= (cuff_start * sign)
luma[in_cuff] = LUMA_CUFF
return mask, luma
v_top = thr["v_top_z"]
v_bot = thr["v_bot_z"]
# V half-width narrows linearly from v_half_top at the collar to 0 at v_bot.
t = np.clip((Z - v_bot) / max(v_top - v_bot, 1e-6), 0.0, None)
vhalf = thr["v_half_top"] * t
collar = (Z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"])
shirt = front & ~collar & (Z >= v_bot) & (Z < v_top) & (ax < vhalf)
lapel_out = vhalf + thr["lapel_w"]
lapel = (front & ~collar & ~shirt
& (Z >= v_bot - thr["lapel_drop"]) & (Z < v_top)
& (ax >= vhalf) & (ax < lapel_out))
mask[:, 0][collar | lapel] = 1.0 # R = lapels + collar band
mask[:, 3][shirt] = 1.0 # A = shirt triangle
body = ~(collar | shirt | lapel)
mask[:, 1][body] = 1.0 # G = jacket body
# ---- painted luma detail ----
luma[collar | lapel] = LUMA_SATIN
# lapel fold (inner) + edge (outer) shading lines
edge = lapel & ((ax < vhalf + thr["edge_w"]) | (ax > lapel_out - thr["edge_w"]))
luma[edge] = LUMA_EDGE
# shirt triangle: base, placket, buttons
luma[shirt] = LUMA_SHIRT
luma[shirt & (ax < thr["placket_half"])] = LUMA_PLACKET
for bz in thr["shirt_btn_zs"]:
btn = shirt & ((X ** 2 + (Z - bz) ** 2) < thr["shirt_btn_r"] ** 2)
luma[btn] = LUMA_SHIRT_BTN
# jacket closure seam + button below the V point
closure = body & front & (Z < v_bot) & (ax < thr["closure_half"])
luma[closure] = LUMA_CLOSURE
jbtn = front & ((X ** 2 + (Z - thr["jacket_btn_z"]) ** 2)
< thr["jacket_btn_r"] ** 2)
luma[jbtn] = LUMA_JACKET_BTN
return mask, luma
def bake_albedo_and_mask(shell, thr, albedo_path, mask_path, albedo_name):
"""One rasterization pass -> painted albedo PNG + RGBA region mask PNG.
Per texel the 3D body-local position is barycentric-interpolated, so paint
and regions are classified per PIXEL (crisp V edges on coarse topology).
Mask background = body green (bilinear bleed stays tinted); albedo
background = fabric luma.
"""
W = H = BAKE_SIZE
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
mask_buf[:, :, 1] = 1.0
albedo_buf = np.full((H, W), LUMA_FABRIC, 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 bake")
counts = {"collar_lapel": 0, "body": 0, "sleeve": 0, "shirt": 0}
for face in bm.faces:
center = face.calc_center_median()
face_sleeve = abs(center.x) >= thr["sleeve_x_abs"]
face_side = 1 if center.x >= 0.0 else -1
loops = face.loops[:]
uvs = [lo[uv_layer].uv for lo in loops]
cos = [lo.vert.co for lo in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_pos(
mask_buf, albedo_buf,
uvs[0], uvs[i], uvs[i + 1],
cos[0], cos[i], cos[i + 1],
face_sleeve, face_side, thr, W, H, counts)
bm.free()
log("bake texel classes: " + " ".join(f"{k}={v}" for k, v in counts.items()))
# ---- save mask ----
img = bpy.data.images.new("jacket_region_mask", W, H, alpha=True)
img.pixels.foreach_set(mask_buf.reshape(-1))
img.update()
img.filepath_raw = mask_path
img.file_format = 'PNG'
img.save()
log(f"baked region mask -> {mask_path}")
# ---- albedo: neutral grey luma + blocky woven noise, slightly cool ----
rng = np.random.default_rng(1089)
coarse = (rng.random((H // 4, W // 4), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
noise = np.kron(coarse, np.ones((4, 4), dtype=np.float32))
lum = np.clip(albedo_buf + noise, 0.0, 1.0)
rgba = np.empty((H, W, 4), dtype=np.float32)
for c, mul in enumerate(ALBEDO_TINT):
rgba[:, :, c] = np.clip(lum * mul, 0.0, 1.0)
rgba[:, :, 3] = 1.0
aimg = bpy.data.images.new(albedo_name, W, H, alpha=False)
aimg.pixels.foreach_set(rgba.reshape(-1))
aimg.update()
aimg.filepath_raw = albedo_path
aimg.file_format = 'PNG'
aimg.save()
log(f"painted albedo -> {albedo_path}")
return aimg
def _raster_tri_pos(mask_buf, albedo_buf, a, b, c, pa, pb, pc,
face_sleeve, face_side, thr, W, H, counts):
"""Barycentric fill interpolating 3D position for per-pixel classification."""
ax_, ay_ = a.x * (W - 1), a.y * (H - 1)
bx_, by_ = b.x * (W - 1), b.y * (H - 1)
cx_, cy_ = c.x * (W - 1), 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
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
X = w0i * pa.x + w1i * pb.x + w2i * pc.x
Y = w0i * pa.y + w1i * pb.y + w2i * pc.y
Z = w0i * pa.z + w1i * pb.z + w2i * pc.z
mask_px, luma_px = _classify_pixels(
X.astype(np.float32), Y.astype(np.float32), Z.astype(np.float32),
face_sleeve, face_side, thr)
counts["collar_lapel"] += int((mask_px[:, 0] > 0.5).sum())
counts["shirt"] += int((mask_px[:, 3] > 0.5).sum())
counts["sleeve"] += int((mask_px[:, 2] > 0.5).sum())
counts["body"] += int((mask_px[:, 1] > 0.5).sum())
m_region = mask_buf[miny:maxy + 1, minx:maxx + 1, :]
a_region = albedo_buf[miny:maxy + 1, minx:maxx + 1]
m_region[inside] = mask_px
a_region[inside] = luma_px
# --------------------------------------------------------------------------
# Parked UV2 (suit is not logo-capable; keep mesh format pipeline-consistent)
# --------------------------------------------------------------------------
def author_parked_uv2(shell):
me = shell.data
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
me.uv_layers.new(name="logo_uv")
me.uv_layers.active = me.uv_layers[0]
bm = bmesh.new()
bm.from_mesh(me)
uvl = bm.loops.layers.uv.get("logo_uv")
for face in bm.faces:
for loop in face.loops:
loop[uvl].uv = (2.0, 2.0)
bm.to_mesh(me)
bm.free()
me.update()
log("UV2 authored fully parked (non-logo garment)")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac):
base.clear_scene()
base.COVERED_SEGMENTS = JACKET_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
thr = derive_jacket_thresholds(armature, wrist_frac)
weld_segment_boundaries(shell)
wrist_cut(shell, thr)
base.offset_outward(shell, offset)
base.solidify(shell, base.CLOTH_THICKNESS_M)
# Stage the painted albedo OUTSIDE the Godot project as "base_albedo.png"
# — the glTF image URI takes the file basename, so Godot's on-import
# extraction lands at <out_dir>/<body>_base_albedo.png (tshirt layout)
# without a duplicate authored sidecar next to it.
body_stage = os.path.join(stage_dir, body)
os.makedirs(body_stage, exist_ok=True)
albedo_img = bake_albedo_and_mask(
shell, thr,
os.path.join(body_stage, "base_albedo.png"),
os.path.join(out_dir, f"{body}_mask.png"),
f"jacket_albedo_{body}")
base.assign_fabric_material(shell, albedo_img)
author_parked_uv2(shell)
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] [--wrist-frac F]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = JACKET_OFFSET_M
wrist_frac = WRIST_FRAC
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--wrist-frac" in argv:
wrist_frac = float(argv[argv.index("--wrist-frac") + 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)
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
stage_dir = os.path.join(repo_root, ".cache", "garment-fit-suit")
log(f"jacket per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
f"wrist frac {wrist_frac}")
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_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
results.append((body, f"error: {exc}"))
ref = base.REFERENCE_BODY
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
if os.path.isfile(ref_mask):
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
ref_alb = os.path.join(stage_dir, ref, "base_albedo.png")
if os.path.isfile(ref_alb):
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
log(f"copied staged {ref} albedo -> base_albedo.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()
@@ -0,0 +1,524 @@
"""
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:
tooling/blender --background --python \
tooling/garment-fit/blender_author_lower_shell.py -- \
<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()
@@ -0,0 +1,533 @@
"""
blender_author_offset_coverall.py (T-1089, uniform_utility — route (c) proof)
Full-body COVERALL authored via the offset-shell technique, per body. Companion
to blender_author_offset_shell.py (imported as a module): reuses its scene
plumbing, offset/solidify, logo-UV2 authoring, rasterizer and export — and
extends it with the coverall-specific geometry and the four-region utility
mask that this garment exists to prove:
ONE garment covering torso + torso_upper + arms + hips + legs. The upper and
lower shells join at the waist for free: adjacent body segments duplicate a
coincident overlap band (probe: median nearest-vertex distance 0.0 in every
seam band), so the joined mesh has no gap by construction and the duplicated
faces offset identically (same verts, same normals, same atlas UVs) and
render invisibly.
Geometry beyond the base script (parameterised, not hard-coded):
- WRIST cut: trim lowerarm tubes at a fraction along the lowerarm bone
(full sleeve ending in a cuff above the hand).
- ANKLE cut: trim calf tubes at a fraction along the calf bone (leg ends in
a cuff above the boot line; feet stay free for footwear garments).
Both are bone-landmark fractions, so every body derives its own planes.
Region mask (the multi-region showcase, R/G/B/A -> tint_0..3):
G = main body fabric
R = trim: collar band + arm cuffs + leg cuffs
B = belt line (waist band, hides the segment seam) + chest patch (logo box,
logo-capable) + right-thigh utility patch
A = shoulder marks (top-facing epaulette strap on each shoulder)
Painted albedo details (flat, toon-friendly; identity lives in the texture):
per-region flat luminance fills (dark belt webbing, bright patches, mid
shoulder marks), a brighter buckle plate at the front-centre of the belt,
and dark stitch lines rasterised along every region-boundary edge (patch
borders, collar seam, cuff seams, belt edges). The toon_garment shader is
luminance-preserving, so these survive any region recolor.
Per-body only (this garment ships per-body per the Q-060 route guidance):
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_coverall.py -- \
<bodies_root> <out_dir> [--bodies a,b,c] [--offset 0.012]
Writes per body:
<out_dir>/<body>.glb skinned coverall authored on that body
<out_dir>/<body>_mask.png RGBA region mask (that body's atlas UVs)
<out_dir>/<body>_base_albedo.png painted detail albedo (also in the GLB)
Plus:
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
<out_dir>/base_albedo.png copy of average_m's albedo (convention)
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
(per-body authoring for offset shells).
"""
import sys
import os
import shutil
import bpy
import bmesh
import numpy as np
# Make the sibling base module importable when Blender runs this file directly.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import blender_author_offset_shell as base # noqa: E402
# --------------------------------------------------------------------------
# Coverall parameters (all bone-landmark fractions unless noted)
# --------------------------------------------------------------------------
COVERED_SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
"seg_hips",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
WRIST_CUT_FRAC = 0.88 # keep this fraction of the lowerarm (elbow->wrist)
ANKLE_CUT_FRAC = 0.82 # keep this fraction of the calf (knee->ankle)
CUFF_ARM_START_FRAC = 0.60 # cuff band = beyond this fraction of the lowerarm
CUFF_LEG_START_FRAC = 0.56 # cuff band = beyond this fraction of the calf
# Belt: centred between pelvis head and spine_01 head (the torso|hips seam
# band sits at ~0.90 of that span on average_m), half-height in ref metres
# scaled by each body's pelvis->spine_01 span.
BELT_CENTER_FRAC = 0.90
BELT_HALF_M_REF = 0.032
BUCKLE_X_ABS_REF = 0.045 # front-centre belt faces within this |x| = buckle plate
# Shoulder marks: top-facing band above the upperarm head, outside the collar.
SHOULDER_Z_FRAC = 0.18 # of (neck head z - upperarm head z), above upperarm head
SHOULDER_X_MAX_FRAC = 1.38 # of shoulder |x|
SHOULDER_NORMAL_Z_MIN = 0.45 # surface must point upward
# Chest patch: the logo box inset by this fraction of its width/height per
# side, so the amber patch frames the decal instead of touching the box edge.
CHEST_PATCH_INSET_FRAC = 0.10
# Right-thigh utility patch: box along the thigh bone, front-facing.
THIGH_PATCH_SIDE = "thigh_r"
THIGH_PATCH_Z_FRACS = (0.30, 0.58) # fraction down the thigh bone
THIGH_PATCH_HALF_X_REF = 0.058
THIGH_PATCH_NORMAL_Y_MIN = 0.10 # forward-facing (front = -Y, base.FRONT_Y_SIGN)
# Painted-albedo luminance per region label (flat toon fills).
ALBEDO_LUMA = {
"body": 0.62,
"collar": 0.66,
"cuff": 0.66,
"shoulder": 0.50,
"belt": 0.34,
"buckle": 0.82,
"patch": 0.72,
}
STITCH_LUMA = 0.30 # dark seam/stitch lines on region boundaries
ALBEDO_NOISE = 0.03 # +/- woven-feel jitter (matches the base script)
# Label ids index the vectorised classifier's output arrays.
LABELS = ["body", "collar", "cuff", "shoulder", "belt", "buckle", "patch"]
LABEL_ID = {name: i for i, name in enumerate(LABELS)}
LABEL_RGBA = {
"collar": (1.0, 0.0, 0.0, 0.0), # R trim
"cuff": (1.0, 0.0, 0.0, 0.0), # R trim
"body": (0.0, 1.0, 0.0, 0.0), # G main fabric
"belt": (0.0, 0.0, 1.0, 0.0), # B belt + patches
"buckle": (0.0, 0.0, 1.0, 0.0), # B
"patch": (0.0, 0.0, 1.0, 0.0), # B
"shoulder": (0.0, 0.0, 0.0, 1.0), # A shoulder marks
}
LABEL_RGBA_ARR = np.array([LABEL_RGBA[n] for n in LABELS], dtype=np.float32)
LABEL_LUMA_ARR = np.array([ALBEDO_LUMA[n] for n in LABELS], dtype=np.float32)
_REF_SHOULDER_X = 0.1919 # average_m upperarm head |x| (same anchor as base)
log = base.log
# --------------------------------------------------------------------------
# Threshold derivation (extends base.derive_thresholds with coverall zones)
# --------------------------------------------------------------------------
def derive_coverall_thresholds(armature):
thr = base.derive_thresholds(armature)
bones = armature.data.bones
def bone(name):
b = bones.get(name)
if b is None:
raise RuntimeError(f"landmark bone {name} missing")
return b
ua = bone("upperarm_l")
neck = bone("neck_01")
pelvis = bone("pelvis")
spine01 = bone("spine_01")
shoulder_x = abs(ua.head_local.x)
scale = shoulder_x / _REF_SHOULDER_X
# Wrist cut planes + arm cuff start, per side (arm runs along +/-X).
for side, sign in (("l", +1), ("r", -1)):
la = bone(f"lowerarm_{side}")
hx, tx = la.head_local.x, la.tail_local.x
thr[f"wrist_cut_x_{side}"] = hx + WRIST_CUT_FRAC * (tx - hx)
thr[f"cuff_arm_x_{side}"] = hx + CUFF_ARM_START_FRAC * (tx - hx)
# Ankle cut + leg cuff start (leg runs down -Z; both calves share z).
calf = bone("calf_l")
hz, tz = calf.head_local.z, calf.tail_local.z
thr["ankle_cut_z"] = hz + ANKLE_CUT_FRAC * (tz - hz)
thr["cuff_leg_z"] = hz + CUFF_LEG_START_FRAC * (tz - hz)
# Belt band.
pz, sz = pelvis.head_local.z, spine01.head_local.z
span = sz - pz
thr["belt_z"] = pz + BELT_CENTER_FRAC * span
thr["belt_half"] = BELT_HALF_M_REF * scale
thr["buckle_x_abs"] = BUCKLE_X_ABS_REF * scale
# Shoulder marks.
uz = ua.head_local.z
thr["shoulder_z_min"] = uz + SHOULDER_Z_FRAC * (neck.head_local.z - uz)
thr["shoulder_x_max"] = shoulder_x * SHOULDER_X_MAX_FRAC
# Chest patch = logo box inset a little on every side.
cx0, cx1 = thr["chest_x"]
cz0, cz1 = thr["chest_z"]
dx = (cx1 - cx0) * CHEST_PATCH_INSET_FRAC
dz = (cz1 - cz0) * CHEST_PATCH_INSET_FRAC
thr["patch_x"] = (cx0 + dx, cx1 - dx)
thr["patch_z"] = (cz0 + dz, cz1 - dz)
# Right-thigh patch box.
thigh = bone(THIGH_PATCH_SIDE)
thz, ttz = thigh.head_local.z, thigh.tail_local.z
f0, f1 = THIGH_PATCH_Z_FRACS
thr["thigh_patch_z"] = (thz + f1 * (ttz - thz), thz + f0 * (ttz - thz))
tx_ctr = thigh.head_local.x
half = THIGH_PATCH_HALF_X_REF * scale
thr["thigh_patch_x"] = (tx_ctr - half, tx_ctr + half)
log(
"coverall thresholds: "
f"wrist_l x>{thr['wrist_cut_x_l']:.3f} wrist_r x<{thr['wrist_cut_x_r']:.3f} "
f"ankle z<{thr['ankle_cut_z']:.3f} "
f"belt z={thr['belt_z']:.3f}+/-{thr['belt_half']:.3f} "
f"shoulder z>={thr['shoulder_z_min']:.3f} "
f"thigh_patch x=({thr['thigh_patch_x'][0]:.3f},{thr['thigh_patch_x'][1]:.3f}) "
f"z=({thr['thigh_patch_z'][0]:.3f},{thr['thigh_patch_z'][1]:.3f})"
)
return thr
# --------------------------------------------------------------------------
# Limb cuts (wrists + ankles)
# --------------------------------------------------------------------------
def limb_cuts(shell, thr):
"""Delete verts beyond the wrist planes (|X|) and below the ankle plane (Z)."""
wl = thr["wrist_cut_x_l"]
wr = thr["wrist_cut_x_r"]
az = thr["ankle_cut_z"]
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 > wl or v.co.x < wr or v.co.z < az
]
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"limb cuts removed {len(to_delete)} verts "
f"(wrist x>{wl:.3f}/x<{wr:.3f}, ankle z<{az:.3f}); "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# Region classification (vectorised, per texel)
#
# Face-granular classification made the patch/belt/shoulder boundaries follow
# the triangulation (jagged, torn-looking zones on the first preview). The
# bake therefore classifies every TEXEL: barycentric interpolation gives each
# covered texel a body-space position + smoothed vertex normal, and the zone
# boundaries land exactly where the thresholds say — crisp at mask resolution.
# --------------------------------------------------------------------------
def classify_texels(pos, nrm, thr):
"""Classify N texels. pos/nrm are (N,3) body-local arrays.
Returns (N,) uint8 label ids (indices into LABELS). Precedence: collar,
cuffs, shoulder marks, belt/buckle, chest patch, thigh patch, body.
"""
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
fy = y * base.FRONT_Y_SIGN
fwd = nrm[:, 1] * base.FRONT_Y_SIGN
ax = np.abs(x)
lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8)
remaining = np.ones(x.shape, dtype=bool)
def take(cond, name):
m = cond & remaining
lab[m] = LABEL_ID[name]
remaining[m] = False
take((z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"]), "collar")
take((x >= thr["cuff_arm_x_l"]) | (x <= thr["cuff_arm_x_r"]), "cuff")
take(z <= thr["cuff_leg_z"], "cuff")
take(
(z >= thr["shoulder_z_min"])
& (ax >= thr["collar_x_abs"]) & (ax <= thr["shoulder_x_max"])
& (nrm[:, 2] > SHOULDER_NORMAL_Z_MIN),
"shoulder",
)
belt = np.abs(z - thr["belt_z"]) <= thr["belt_half"]
take(belt & (fy > 0.0) & (ax < thr["buckle_x_abs"]), "buckle")
take(belt, "belt")
px0, px1 = thr["patch_x"]
pz0, pz1 = thr["patch_z"]
take(
(fy > base.CHEST_FRONT_Y)
& (x >= px0) & (x <= px1) & (z >= pz0) & (z <= pz1)
& (fwd > base.CHEST_NORMAL_Y),
"patch",
)
tx0, tx1 = thr["thigh_patch_x"]
tz0, tz1 = thr["thigh_patch_z"]
take(
(fy > 0.0)
& (x >= tx0) & (x <= tx1) & (z >= tz0) & (z <= tz1)
& (fwd > THIGH_PATCH_NORMAL_Y_MIN),
"patch",
)
return lab
# --------------------------------------------------------------------------
# Combined mask + painted-albedo bake (one classification pass)
# --------------------------------------------------------------------------
def _tri_texels(a, b, c, W, H):
"""Texels covered by UV triangle (a,b,c) with barycentric weights.
Same maths as base._raster_tri, but returns (ys, xs, w0, w1, w2) arrays
instead of writing a flat colour, so the caller can interpolate per-texel
attributes (position, normal) across the triangle.
"""
empty = (np.empty(0, int),) * 2 + (np.empty(0, np.float32),) * 3
ax, ay = a.x * (W - 1), a.y * (H - 1)
bx, by = b.x * (W - 1), b.y * (H - 1)
cx, cy = c.x * (W - 1), 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 empty
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
if abs(denom) < 1e-9:
return empty
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 empty
return (
ys[inside], xs[inside],
w0[inside].astype(np.float32),
w1[inside].astype(np.float32),
w2[inside].astype(np.float32),
)
def bake_mask_and_albedo(shell, thr, mask_path, albedo_name, seed):
"""One pass over the faces: bake the RGBA region mask AND the painted
detail albedo (flat per-region luminance + stitch lines + woven noise).
Classification is per TEXEL (interpolated position + smoothed vertex
normal), so zone boundaries are crisp at mask resolution instead of
following the triangulation.
"""
W = H = base.MASK_SIZE
mask = np.zeros((H, W, 4), dtype=np.float32)
mask[:, :, 1] = 1.0 # green background = main body (bilinear-bleed safe)
albedo = np.zeros((H, W, 4), dtype=np.float32)
albedo[:, :, 0:3] = ALBEDO_LUMA["body"]
albedo[:, :, 3] = 1.0
label_map = np.full((H, W), LABEL_ID["body"], dtype=np.uint8)
covered = np.zeros((H, W), dtype=bool)
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 bake")
for face in bm.faces:
loops = face.loops[:]
uvs = [loop[uv_layer].uv for loop in loops]
pos = [loop.vert.co for loop in loops]
nrm = [loop.vert.normal for loop in loops]
for i in range(1, len(loops) - 1):
tri = (0, i, i + 1)
ys, xs, w0, w1, w2 = _tri_texels(uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H)
if ys.size == 0:
continue
p = np.empty((ys.size, 3), dtype=np.float32)
n = np.empty((ys.size, 3), dtype=np.float32)
for axis in range(3):
p[:, axis] = (w0 * pos[tri[0]][axis] + w1 * pos[tri[1]][axis]
+ w2 * pos[tri[2]][axis])
n[:, axis] = (w0 * nrm[tri[0]][axis] + w1 * nrm[tri[1]][axis]
+ w2 * nrm[tri[2]][axis])
n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9)
lab = classify_texels(p, n, thr)
label_map[ys, xs] = lab
covered[ys, xs] = True
mask[ys, xs] = LABEL_RGBA_ARR[lab]
albedo[ys, xs, 0:3] = LABEL_LUMA_ARR[lab][:, None]
total = max(int(covered.sum()), 1)
tex_counts = np.bincount(label_map[covered], minlength=len(LABELS))
log("region texels: " + " ".join(
f"{LABELS[i]}={int(c)} ({100.0 * c / total:.1f}%)"
for i, c in enumerate(tex_counts) if c > 0))
# Woven-feel noise over the fills, before stitch lines (lines stay crisp).
rng = np.random.default_rng(seed)
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
albedo[:, :, 0:3] = np.clip(albedo[:, :, 0:3] + noise, 0.0, 1.0)
# Stitch lines: texel-space label transitions where BOTH texels belong to
# rasterised geometry (skipping UV-island borders against background).
# Buckle|belt stays seamless (same physical strap).
lm = np.where(label_map == LABEL_ID["buckle"], LABEL_ID["belt"], label_map)
edge = np.zeros((H, W), dtype=bool)
dh = (lm[:, 1:] != lm[:, :-1]) & covered[:, 1:] & covered[:, :-1]
edge[:, 1:] |= dh
edge[:, :-1] |= dh
dv = (lm[1:, :] != lm[:-1, :]) & covered[1:, :] & covered[:-1, :]
edge[1:, :] |= dv
edge[:-1, :] |= dv
albedo[edge, 0:3] = STITCH_LUMA
bm.free()
log(f"stitch lines on {int(edge.sum())} boundary texels")
# Floor the alpha channel at 2/255: Godot's default texture import runs
# process/fix_alpha_border, which rewrites the RGB of fully-transparent
# texels bordering opaque ones — that would smear the shoulder-mark island
# edges into the surrounding body-green weights. With no alpha-0 texels the
# pass is a no-op; the 0.8% tint_3 weight it adds everywhere is invisible.
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True)
# The mask is channel-packed DATA (R/G/B/A region weights), not imagery
# with transparency: without this, Blender's straight-alpha PNG save
# zeroes the A channel (verified: shoulder-mark texels came back A=0).
img_mask.alpha_mode = 'CHANNEL_PACKED'
img_mask.pixels.foreach_set(mask.reshape(-1))
img_mask.update()
img_mask.filepath_raw = mask_path
img_mask.file_format = 'PNG'
img_mask.save()
log(f"baked region mask -> {mask_path}")
img_albedo = bpy.data.images.new(f"albedo_{albedo_name}", W, H, alpha=False)
img_albedo.pixels.foreach_set(albedo.reshape(-1))
img_albedo.update()
return img_albedo
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_coverall(body_dir, out_dir, body, offset, seed):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this
shell, armature = base.build_covered_mesh(body_dir)
thr = derive_coverall_thresholds(armature)
limb_cuts(shell, thr)
base.offset_outward(shell, offset)
# Author UV2 + bake mask/albedo BEFORE solidify: the inner shell that
# solidify adds duplicates every face with the SAME atlas UVs but a
# flipped normal — the normal-gated rules (shoulder marks, patches) would
# classify those copies as body and overwrite the very texels the outer
# faces just wrote. Pre-solidify there is exactly one face per texel.
base.author_logo_uv(shell, thr) # UV2 (inner shell inherits it, harmless)
albedo_img = bake_mask_and_albedo(
shell, thr, os.path.join(out_dir, f"{body}_mask.png"), body, seed)
base.solidify(shell, base.CLOTH_THICKNESS_M)
base.assign_fabric_material(shell, albedo_img)
# Save the albedo under the SHARED name before export: the glTF exporter
# names the embedded texture after the image filepath basename, and the
# Godot GLB import extracts it as <glb>_<texname>.png — with the shared
# name that lands on the tshirt-convention <body>_base_albedo.png (a
# <body>-specific filepath here would yield <body>_<body>_base_albedo.png).
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"))
# Keep a deterministic authored per-body sidecar as well.
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
os.path.join(out_dir, f"{body}_base_albedo.png"))
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 = base.PER_BODY_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"coverall per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
results = []
for i, body in enumerate(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_coverall(body_dir, out_dir, body, offset, seed=1089 + i)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
# Runtime fallback: reference_mask.png + base_albedo.png mirror 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(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
# base_albedo.png currently holds the LAST body's albedo; restore the
# reference body's copy so the shared sidecar is deterministic.
ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
if os.path.isfile(ref_albedo):
shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
log(f"copied {base.REFERENCE_BODY}_base_albedo.png -> base_albedo.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()
@@ -27,24 +27,51 @@ Pipeline (t-shirt reference on average_m):
9. Export the reference GLB (export_skins=True) + write reference_mask.png and
base_albedo.png sidecars.
The output reference is authored on average_m only; G1
(blender_batch_fit_skinned.py) fits it to the other body types.
Two modes (T-1089):
SINGLE-REFERENCE (default) — author on one body (average_m); G1
(blender_batch_fit_skinned.py) SD-fits it to the other body types. Right for
derived/hand-authored garments that share one UV layout + one mask.
Run:
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell.py -- \
<bodies_dir>/average_m <out_dir> [--offset 0.012] [--sleeve-frac 0.40]
<bodies_dir>/average_m <out_dir> [--offset 0.020] [--sleeve-frac 0.40]
Writes:
<out_dir>/average_m.glb reference garment (skinned)
<out_dir>/reference_mask.png RGBA region mask (UV0-aligned)
<out_dir>/base_albedo.png flat fabric albedo (also embedded in the GLB)
Writes:
<out_dir>/average_m.glb reference garment (skinned)
<out_dir>/reference_mask.png RGBA region mask (UV0-aligned)
<out_dir>/base_albedo.png flat fabric albedo (also embedded in the GLB)
PER-BODY (--per-body) — author the shell from EACH body's own segment meshes.
QA evidence (Q-060): single-reference SD-fit of offset-shells degrades with
girth divergence (muscular_m 859px worst clip at 24 mm standoff). Authoring
per body gives guaranteed standoff + exact weights by construction, and lets
the offset drop back to the ~12 mm ideal. Cut/mask parameters are derived
from each body's OWN bone landmarks using the same proportional ratios
(anchored to reproduce the hand-calibrated average_m constants exactly), so
regions stay consistent across bodies.
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell.py -- \
<bodies_dir> <out_dir> --per-body \
[--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40]
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 shared flat fabric albedo (deterministic)
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
The runtime compositor (character_visual.gd) prefers <body>_mask.png and
falls back to reference_mask.png for SD-fit garments.
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
"""
import sys
import os
import shutil
import bpy
import bmesh
import numpy as np
@@ -56,15 +83,25 @@ import numpy as np
# Segments a t-shirt covers. Order matters only for join-active choice.
COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r"]
OFFSET_M = 0.020 # outward standoff along vertex normals; larger than the
# 10-14 mm ideal on the reference body buys clearance for
# bigger bodies under Surface-Deform batch-fit (Q-060) —
# the muscular/female torso otherwise pokes through.
CLOTH_THICKNESS_M = 0.004 # Solidify thickness after offset (total ~24 mm standoff)
OFFSET_M = 0.020 # single-reference standoff along vertex normals; larger
# than the 10-14 mm ideal on the reference body buys
# clearance for bigger bodies under Surface-Deform
# batch-fit (Q-060) — the muscular/female torso otherwise
# pokes through.
PER_BODY_OFFSET_M = 0.012 # per-body standoff — each body's own surface guarantees
# clearance by construction, so the ideal applies.
CLOTH_THICKNESS_M = 0.004 # Solidify thickness after offset
SLEEVE_FRAC = 0.40 # fraction of upper-arm length kept (short sleeve)
MASK_SIZE = 512
ALBEDO_SIZE = 512
# All shipping body types (matches blender_batch_fit_skinned.py / the runtime).
BODY_TYPES = [
"average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f",
"heavy_m", "heavy_f", "teen_m", "teen_f", "child",
]
REFERENCE_BODY = "average_m"
# Flat modern-fabric base tone (linear-ish sRGB), neutral heather grey.
FABRIC_RGB = (0.60, 0.61, 0.63)
FABRIC_NOISE = 0.03 # +/- albedo jitter for a subtle woven feel
@@ -83,11 +120,75 @@ COLLAR_Z_MIN = 1.49 # faces above this AND near centre -> collar band (R)
COLLAR_X_ABS = 0.11 # collar band stays near the neck, not the shoulders
SLEEVE_X_ABS = 0.20 # faces with |center X| beyond this -> sleeve cap (B)
# --------------------------------------------------------------------------
# Per-body threshold derivation (T-1089 per-body shell mode)
#
# The absolute constants above were hand-calibrated on average_m. The ratios
# below re-express every one of them against average_m's bone landmarks
# (shoulder = upperarm head |x| 0.1919, neck_01 head z 1.5205 / length 0.0793,
# spine_01 head z 1.072) so any body derives the SAME proportional cut/mask
# parameters from its own armature. On average_m the derivation reproduces
# the legacy constants exactly; the 11 bodies share one 65-bone rig, so the
# landmarks exist everywhere.
# --------------------------------------------------------------------------
_REF_SHOULDER_X = 0.1919
_REF_NECK_Z = 1.5205
_REF_NECK_LEN = 0.0793
_REF_SPINE_LO_Z = 1.072
SLEEVE_X_FRAC = SLEEVE_X_ABS / _REF_SHOULDER_X # of shoulder |x|
COLLAR_X_FRAC = COLLAR_X_ABS / _REF_SHOULDER_X # of shoulder |x|
COLLAR_DROP_FRAC = (_REF_NECK_Z - COLLAR_Z_MIN) / _REF_NECK_LEN # below neck head
CHEST_X_FRAC = CHEST_X[1] / _REF_SHOULDER_X # of shoulder |x|
_REF_SPINE_SPAN = _REF_NECK_Z - _REF_SPINE_LO_Z
CHEST_Z_LO_FRAC = (CHEST_Z[0] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN
CHEST_Z_HI_FRAC = (CHEST_Z[1] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN
def log(msg):
print(f"[offset-shell] {msg}")
def derive_thresholds(armature):
"""Derive cut/mask thresholds from this body's bone landmarks.
Returns a dict {sleeve_x_abs, collar_z_min, collar_x_abs, chest_x, chest_z}.
Falls back to the legacy average_m constants when landmarks are missing.
"""
bones = armature.data.bones
ua_l = bones.get("upperarm_l")
ua_r = bones.get("upperarm_r")
neck = bones.get("neck_01")
spine01 = bones.get("spine_01")
if not all([ua_l, ua_r, neck, spine01]):
log("WARNING: landmark bones missing — using legacy average_m thresholds")
return {
"sleeve_x_abs": SLEEVE_X_ABS,
"collar_z_min": COLLAR_Z_MIN,
"collar_x_abs": COLLAR_X_ABS,
"chest_x": CHEST_X,
"chest_z": CHEST_Z,
}
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
neck_z = neck.head_local.z
neck_len = neck.tail_local.z - neck.head_local.z
spine_lo = spine01.head_local.z
spine_span = neck_z - spine_lo
thr = {
"sleeve_x_abs": shoulder_x * SLEEVE_X_FRAC,
"collar_z_min": neck_z - COLLAR_DROP_FRAC * neck_len,
"collar_x_abs": shoulder_x * COLLAR_X_FRAC,
"chest_x": (-shoulder_x * CHEST_X_FRAC, shoulder_x * CHEST_X_FRAC),
"chest_z": (spine_lo + CHEST_Z_LO_FRAC * spine_span,
spine_lo + CHEST_Z_HI_FRAC * spine_span),
}
log(f"thresholds: sleeve |x|>={thr['sleeve_x_abs']:.3f} "
f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_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
# --------------------------------------------------------------------------
# Scene helpers
# --------------------------------------------------------------------------
@@ -290,12 +391,12 @@ def assign_fabric_material(shell, albedo_img):
# Region mask bake (UV0-aligned, per-face rasterization)
# --------------------------------------------------------------------------
def _classify_region(center):
def _classify_region(center, thr):
"""Return an RGBA region colour for a face centre (body-local coords)."""
x, y, z = center.x, center.y, center.z
if abs(x) >= SLEEVE_X_ABS:
x, z = center.x, center.z
if abs(x) >= thr["sleeve_x_abs"]:
return (0.0, 0.0, 1.0, 0.0) # sleeve caps -> B (tint[2])
if z >= COLLAR_Z_MIN and abs(x) < COLLAR_X_ABS:
if z >= thr["collar_z_min"] and abs(x) < thr["collar_x_abs"]:
return (1.0, 0.0, 0.0, 0.0) # neck collar band -> R (tint[0])
return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1])
@@ -310,7 +411,7 @@ def _tris_from_face(face, uv_layer):
return tris
def bake_region_mask(shell, out_path):
def bake_region_mask(shell, out_path, thr):
"""Rasterize each face's UV0 triangle with its region colour into MASK_SIZE^2.
Background initialised to main-body green so bilinear bleed at island edges
@@ -328,12 +429,21 @@ def bake_region_mask(shell, out_path):
if uv_layer is None:
raise RuntimeError("no active UV layer for region mask bake")
yy, xx = np.mgrid[0:H, 0:W]
region_counts = {"collar": 0, "body": 0, "sleeve": 0}
for face in bm.faces:
color = _classify_region(face.calc_center_median())
color = _classify_region(face.calc_center_median(), thr)
if color[0] > 0.5:
region_counts["collar"] += 1
elif color[2] > 0.5:
region_counts["sleeve"] += 1
else:
region_counts["body"] += 1
for a, b, c in _tris_from_face(face, uv_layer):
_raster_tri(buf, a, b, c, color, W, H)
bm.free()
total = max(sum(region_counts.values()), 1)
log("region faces: " + " ".join(
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in region_counts.items()))
# Blender image is bottom-up; buf row 0 is V=0 (bottom) already since we
# rasterise with row = v*(H-1). Save via Blender to match the texture pipe.
@@ -378,7 +488,7 @@ def _raster_tri(buf, a, b, c, color, W, H):
# Logo UV2 chest channel
# --------------------------------------------------------------------------
def author_logo_uv(shell):
def author_logo_uv(shell, thr):
"""Create a 2nd UV layer projecting front chest faces into [0,1]; park the
rest outside the box (shader guards uv2 in [0,1])."""
me = shell.data
@@ -394,8 +504,8 @@ def author_logo_uv(shell):
bm.normal_update()
uvl = bm.loops.layers.uv.get("logo_uv")
x0, x1 = CHEST_X
z0, z1 = CHEST_Z
x0, x1 = thr["chest_x"]
z0, z1 = thr["chest_z"]
placed = 0
for face in bm.faces:
center = face.calc_center_median()
@@ -463,37 +573,93 @@ def save_albedo_sidecar(albedo_img, out_path):
# Entry
# --------------------------------------------------------------------------
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print("Usage: -- <bodies_dir>/average_m <out_dir> "
"[--offset M] [--sleeve-frac F]")
sys.exit(1)
body_dir = argv[0]
out_dir = argv[1]
def author_shell(body_dir, out_dir, glb_name, mask_name, offset):
"""Author one offset-shell garment from `body_dir`'s segments.
global OFFSET_M, SLEEVE_FRAC
if "--offset" in argv:
OFFSET_M = float(argv[argv.index("--offset") + 1])
if "--sleeve-frac" in argv:
SLEEVE_FRAC = float(argv[argv.index("--sleeve-frac") + 1])
os.makedirs(out_dir, exist_ok=True)
Shared by both modes; thresholds derive from the body's own armature so
the same proportional cut/mask parameters apply on every body.
"""
clear_scene()
shell, armature = build_covered_mesh(body_dir)
thr = derive_thresholds(armature)
sleeve_cut(shell, armature)
offset_outward(shell, OFFSET_M)
offset_outward(shell, offset)
solidify(shell, CLOTH_THICKNESS_M)
albedo_img = make_base_albedo_image()
assign_fabric_material(shell, albedo_img)
author_logo_uv(shell) # do UV2 before mask bake (mask uses UV0/active)
bake_region_mask(shell, os.path.join(out_dir, "reference_mask.png"))
author_logo_uv(shell, thr) # do UV2 before mask bake (mask uses UV0/active)
bake_region_mask(shell, os.path.join(out_dir, mask_name), thr)
save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
export_reference(shell, armature, os.path.join(out_dir, "average_m.glb"))
export_reference(shell, armature, os.path.join(out_dir, glb_name))
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print("Usage: -- <bodies_dir>/average_m <out_dir> "
"[--offset M] [--sleeve-frac F]\n"
" or: -- <bodies_dir> <out_dir> --per-body "
"[--bodies a,b,c] [--offset M] [--sleeve-frac F]")
sys.exit(1)
in_dir = argv[0]
out_dir = argv[1]
per_body = "--per-body" in argv
global SLEEVE_FRAC
offset = None
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--sleeve-frac" in argv:
SLEEVE_FRAC = float(argv[argv.index("--sleeve-frac") + 1])
bodies = 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)
if not per_body:
# Single-reference mode: <in_dir> is one body's segment dir.
offset = OFFSET_M if offset is None else offset
body = os.path.basename(os.path.normpath(in_dir))
author_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png", offset)
log("DONE")
return
# Per-body mode: <in_dir> is the bodies root; loop each body's own segments.
offset = PER_BODY_OFFSET_M if offset is None else offset
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
results = []
for body in bodies:
body_dir = os.path.join(in_dir, body)
log(f"=== {body} ===")
if not os.path.isdir(body_dir):
results.append((body, "skipped: body dir missing"))
continue
try:
author_shell(body_dir, out_dir, f"{body}.glb", f"{body}_mask.png",
offset)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
results.append((body, f"error: {exc}"))
# Runtime fallback + SD-fit reference compatibility: reference_mask.png
# mirrors the reference body's mask.
ref_mask = os.path.join(out_dir, f"{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 {REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
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")
@@ -0,0 +1,373 @@
"""
blender_author_offset_shell_legs.py (T-1089, lower-body offset-shell garments)
Companion to blender_author_offset_shell.py (imported as a module — scene
helpers, offset/solidify, albedo, rasterizer and export are reused, not
copied). Where the base script is calibrated for TORSO garments (sleeve cut,
collar/sleeve regions, chest logo UV), this one authors LOWER-BODY garments
from seg_hips + leg segments with parameterized cuts/regions, so one script
serves shorts, jeans, joggers, formal pants, swim trunks:
--coverage thigh|full thigh: seg_hips + seg_leg_upper_l/r (shorts)
full: + seg_leg_lower_l/r (jeans/joggers/formal)
--hem-frac F fraction of the hem bone's length KEPT below its
head (bone-plane cut). Hem bone = thigh for
--coverage thigh, calf for full. 0.78 on the thigh
= casual shorts hem ~9 cm above the knee.
--waistband-frac F waistband band height as a fraction of
(waist rim z − thigh head z). The band is
classified R in the region mask; everything else
is G. (B/A unused — 2-region garment.)
--fabric R,G,B flat toon-friendly base tone (luma ~0.6 keeps the
toon_garment.gdshader luma-recolor faithful).
--seed N albedo noise seed (deterministic output).
Cut/region thresholds derive PER BODY from that body's own landmarks, same
philosophy as the base script: the hem plane comes from the thigh/calf bone
(head→tail, identical 65-bone rig on all 11 bodies), the waist rim is the
natural top boundary of seg_hips (its own mesh z-max), so proportions match
across bodies. The waistline itself comes free as a segment boundary — no
waist join geometry needed.
Regions (RGBA mask, toon_garment.gdshader): waistband → R (tint_0),
body → G (tint_1). A parked logo_uv TEXCOORD_1 layer is authored for channel
consistency with torso garments (all UVs outside [0,1] → shader draws nothing).
Modes mirror the base script:
PER-BODY (preferred for offset shells, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
<bodies_dir> <out_dir> --per-body \
[--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35]
Writes <out_dir>/<body>.glb + <out_dir>/<body>_mask.png per body, plus
shared base_albedo.png and reference_mask.png (= average_m's mask, runtime
fallback for the compositor).
SINGLE-REFERENCE:
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
<bodies_dir>/average_m <out_dir> [--offset 0.020] [...]
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
"""
import sys
import os
import shutil
import importlib.util
import bpy # noqa: F401 (Blender runtime)
import bmesh
import numpy as np
# --------------------------------------------------------------------------
# Import the base authoring module (shared helpers; main() is __main__-guarded)
# --------------------------------------------------------------------------
_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
# --------------------------------------------------------------------------
# Parameters (defaults = shorts_modern)
# --------------------------------------------------------------------------
COVERAGE_SEGMENTS = {
"thigh": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"],
"full": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r"],
}
HEM_BONES = {"thigh": ("thigh_l", "thigh_r"), "full": ("calf_l", "calf_r")}
HEM_FRAC = 0.78 # keep 78% of the thigh → hem above the knee
WAISTBAND_FRAC = 0.35 # top 35% of waist-rim→thigh-head span = waistband (R)
FABRIC_RGB = (0.63, 0.60, 0.53) # warm stone khaki, luma ~0.60 (toon-friendly)
ALBEDO_SEED = 20890 # deterministic albedo noise, distinct from the tshirt
# --------------------------------------------------------------------------
# Per-body threshold derivation
# --------------------------------------------------------------------------
def derive_leg_thresholds(armature, coverage, hem_frac):
"""Hem plane from this body's own hem bone; thigh head z for the waistband
span. All 11 bodies share the 65-bone rig, so the landmarks always exist."""
bones = armature.data.bones
hem_l, hem_r = HEM_BONES[coverage]
zs = []
thigh_heads = []
for name in (hem_l, hem_r):
b = bones.get(name)
if b is None:
raise RuntimeError(f"hem bone {name} missing on armature")
zs.append(b.head_local.z + hem_frac * (b.tail_local.z - b.head_local.z))
for name in ("thigh_l", "thigh_r"):
b = bones.get(name)
if b is None:
raise RuntimeError(f"landmark bone {name} missing on armature")
thigh_heads.append(b.head_local.z)
thr = {
"hem_z": sum(zs) / len(zs),
"thigh_head_z": sum(thigh_heads) / len(thigh_heads),
}
log(f"thresholds: hem z>={thr['hem_z']:.3f} "
f"(hem bone {hem_l}/{hem_r}, keep {hem_frac:.2f}) "
f"thigh head z={thr['thigh_head_z']:.3f}")
return thr
# --------------------------------------------------------------------------
# Seam weld — the body segment meshes are assembled from patches whose seam
# vertices are coincident but DUPLICATED. Each copy's normal averages only its
# own patch's faces, so the outward offset pulls seams apart (visible slit at
# the front-centre of seg_hips, V-notches on the waist rim). Welding before
# the offset merges the copies (weights/UVs are identical on coincident verts)
# and gives one smooth normal per seam vertex.
# --------------------------------------------------------------------------
def weld_seams(shell, epsilon=1e-4):
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=epsilon)
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
after = len(shell.data.vertices)
log(f"seam weld merged {before - after} duplicate verts; {after} remain")
# --------------------------------------------------------------------------
# Bone-plane hem cut (z threshold — legs hang along -Z in rest pose)
# --------------------------------------------------------------------------
def hem_cut(shell, hem_z):
"""Delete every vert below the hem plane. A single global z threshold is
safe: the hips segment bottoms out far above any sensible hem."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = [v for v in bm.verts if v.co.z < hem_z]
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"hem cut removed {len(to_delete)} verts below z={hem_z:.3f}; "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# Region mask: waistband (R) / body (G)
# --------------------------------------------------------------------------
def bake_region_mask_legs(shell, out_path, thr, waistband_frac):
"""Rasterize UV0 faces: waistband → R, everything else → G.
MUST run PRE-solidify (open boundaries still present). The waistband is
boundary-anchored: the waist rim is the top open boundary loop of seg_hips
(the rim DIPS ~5 cm at the navel, so a global z-max test misses the front
row), so a face is waistband when it touches a rim-boundary vertex — that
keys the full top face row on every tessellation — or when its centre lies
inside the proportional band. Solidify afterwards duplicates the UV loops
unchanged, so the baked texels serve outer shell, inner shell and rim caps
alike."""
W = H = base.MASK_SIZE
buf = np.zeros((H, W, 4), dtype=np.float32)
buf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
bm.verts.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")
waist_rim_z = max(v.co.z for v in bm.verts)
band = waistband_frac * (waist_rim_z - thr["thigh_head_z"])
wb_z_min = waist_rim_z - band
# Waist-rim boundary verts: on an open boundary AND above the thigh head
# (the only other boundaries are the leg hems, far below).
rim_verts = set()
for e in bm.edges:
if e.is_boundary:
for v in e.verts:
if v.co.z >= thr["thigh_head_z"]:
rim_verts.add(v.index)
log(f"waistband: rim z={waist_rim_z:.3f} band {band*100:.1f} cm "
f"→ R for z>={wb_z_min:.3f} or touching {len(rim_verts)} rim verts")
counts = {"waistband": 0, "body": 0}
for face in bm.faces:
in_band = face.calc_center_median().z >= wb_z_min
touches_rim = any(v.index in rim_verts for v in face.verts)
if in_band or touches_rim:
color = (1.0, 0.0, 0.0, 0.0)
counts["waistband"] += 1
else:
color = (0.0, 1.0, 0.0, 0.0)
counts["body"] += 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()))
img = bpy.data.images.new("garment_region_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}")
def park_logo_uv(shell):
"""Author a TEXCOORD_1 layer with every loop parked outside [0,1] — keeps
the UV-channel layout identical to torso garments; the shader's in-box
guard means nothing ever draws."""
me = shell.data
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
me.uv_layers.new(name="logo_uv")
me.uv_layers.active = me.uv_layers[0]
bm = bmesh.new()
bm.from_mesh(me)
uvl = bm.loops.layers.uv.get("logo_uv")
for face in bm.faces:
for loop in face.loops:
loop[uvl].uv = (2.0, 2.0)
bm.to_mesh(me)
bm.free()
me.update()
log("logo UV2 parked (no logo region on this garment)")
# --------------------------------------------------------------------------
# Author one body
# --------------------------------------------------------------------------
def author_legs_shell(body_dir, out_dir, glb_name, mask_name, offset,
coverage, hem_frac, waistband_frac, seed):
base.clear_scene()
base.COVERED_SEGMENTS = COVERAGE_SEGMENTS[coverage]
shell, armature = base.build_covered_mesh(body_dir)
weld_seams(shell)
thr = derive_leg_thresholds(armature, coverage, hem_frac)
hem_cut(shell, thr["hem_z"])
base.offset_outward(shell, offset)
# Region mask + parked logo UV are authored PRE-solidify: the boundary-
# anchored waistband needs the open waist rim, and solidify duplicates all
# UV loops unchanged so the baked texels stay valid for the final mesh.
park_logo_uv(shell)
bake_region_mask_legs(shell, os.path.join(out_dir, mask_name), thr,
waistband_frac)
base.solidify(shell, base.CLOTH_THICKNESS_M)
base.FABRIC_RGB = FABRIC_RGB
albedo_img = base.make_base_albedo_image(seed=seed)
base.assign_fabric_material(shell, albedo_img)
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
base.export_reference(shell, armature, os.path.join(out_dir, glb_name))
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print("Usage: -- <bodies_dir>/average_m <out_dir> [--offset M] "
"[--coverage thigh|full] [--hem-frac F] [--waistband-frac F] "
"[--fabric R,G,B] [--seed N]\n"
" or: -- <bodies_dir> <out_dir> --per-body [--bodies a,b,c] "
"[same flags]")
sys.exit(1)
in_dir = argv[0]
out_dir = argv[1]
per_body = "--per-body" in argv
global FABRIC_RGB
offset = None
coverage = "thigh"
hem_frac = HEM_FRAC
waistband_frac = WAISTBAND_FRAC
seed = ALBEDO_SEED
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--coverage" in argv:
coverage = argv[argv.index("--coverage") + 1]
if coverage not in COVERAGE_SEGMENTS:
print(f"unknown --coverage {coverage}")
sys.exit(1)
if "--hem-frac" in argv:
hem_frac = float(argv[argv.index("--hem-frac") + 1])
if "--waistband-frac" in argv:
waistband_frac = float(argv[argv.index("--waistband-frac") + 1])
if "--fabric" in argv:
FABRIC_RGB = tuple(
float(c) for c in argv[argv.index("--fabric") + 1].split(","))
if "--seed" in argv:
seed = int(argv[argv.index("--seed") + 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)
if not per_body:
offset = base.OFFSET_M if offset is None else offset
body = os.path.basename(os.path.normpath(in_dir))
author_legs_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png",
offset, coverage, hem_frac, waistband_frac, seed)
log("DONE")
return
offset = base.PER_BODY_OFFSET_M if offset is None else offset
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
f"coverage={coverage} hem_frac={hem_frac} waistband_frac={waistband_frac}")
results = []
for body in bodies:
body_dir = os.path.join(in_dir, body)
log(f"=== {body} ===")
if not os.path.isdir(body_dir):
results.append((body, "skipped: body dir missing"))
continue
try:
author_legs_shell(body_dir, out_dir, f"{body}.glb",
f"{body}_mask.png", offset, coverage, hem_frac,
waistband_frac, seed)
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 (fallback)")
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()
@@ -0,0 +1,445 @@
"""
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):
tooling/blender --background --python \
tooling/garment-fit/blender_author_outerwear.py -- \
<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()
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "buttondown_modern", "slot": "torso"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/buttondown_modern"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "hoodie_modern", "slot": "torso"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/hoodie_modern"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "jacket_modern", "slot": "torso"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/jacket_modern"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "jeans_modern", "slot": "legs"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/jeans_modern"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "pants_formal", "slot": "legs"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/pants_formal"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "shorts_modern", "slot": "legs"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/shorts_modern"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "suit_jacket_black", "slot": "torso"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/suit_jacket_black"
}
@@ -2,7 +2,7 @@
"garments": [
{"item_id": "tshirt_modern", "slot": "torso"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f"],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "uniform_utility", "slot": "full_body"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/uniform_utility"
}