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:
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user