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>
530 lines
22 KiB
Python
530 lines
22 KiB
Python
"""
|
|
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()
|