35 payloads move to tooling/scripts/blender/ and stay outside package scope. They run under Blender's bundled Python, which cannot see the repo venv, so they physically cannot import tooling.core — holding them to the D-263 contract would either fail the gate forever or force the contract to be weakened for everyone, and the second is how a gate stops meaning anything. Count verified by import rather than filename: 33 import bpy/bmesh directly, and the two that do not are still payloads per their own usage lines. garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290. The bash wrapper is retired rather than kept. Keeping it would have put the install-resolution logic in two places, which is the duplication T-1286 had just finished collapsing three copies of. domains/blender/service.py owns the decisions — resolve_blender (native beats flatpak, ordering preserved), resolve_payload, absolutise — and only run_payload performs. test_blender.py pins all of them without launching Blender, which matters here more than usual: the thing being launched is a 200 MB GUI application that writes GLBs. `reach blender run` takes a registered payload name OR a path to any script, because the wrapper served both — the spikes and the glb-gen skill hand it one-off scripts of their own. An unknown name enumerates all 35 and exits 2. The exclusion now defends itself. check_carve_out_stays_carved fails if `scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty exclusion proves nothing), or if an __init__.py appears there (which would make the payloads importable — the coupling the carve-out exists to prevent). All three arms mutation-proved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
591 lines
23 KiB
Python
591 lines
23 KiB
Python
"""
|
|
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()
|