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