Files
settled-reach/tooling/scripts/blender/blender_author_track_jacket.py
T
jpmschweitzerandClaude Opus 5 201dabd19b refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
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>
2026-09-02 20:55:52 +02:00

655 lines
27 KiB
Python

"""
blender_author_track_jacket.py (T-1089 wave 2 — track_jacket, sport family)
Authors the TRACK JACKET as per-body offset shells, reusing
blender_author_offset_shell.py as a library (scene build, segment join,
bone-ratio thresholds, offset, solidify, logo UV2, skinned GLB export).
Sport-top sibling of blender_author_outerwear.py (jacket_modern); what track
kit needs beyond that script is delivered as reusable parameters, not hacks:
* SLEEVE STRIPES AS THEIR OWN REGION (the recolorable team stripe): two
parallel stripes run along the TOP of each sleeve from the shoulder seam
to the cuff. Stripe placement uses a per-|x| arm-axis interpolation from
the upperarm/lowerarm bone landmarks (the denim script's per-z leg-axis
technique rotated onto the arms), with constant-metric arc widths, so the
stripes stay parallel on every body.
* Region layout per spec: collar=R, body=G, sleeve stripes=B,
cuffs + hem band=A (toon_garment.gdshader channel-blends 4 tints).
* TEXEL-level combined bake (denim best practice): every UV0 triangle is
rasterized once with barycentric-interpolated 3D positions and ONE
analytic feature-field evaluation drives BOTH the painted albedo and the
region mask, so paint and regions always agree.
* Painted albedo identity (luma-carried, survives any tint): full front
ZIP (bright teeth dashes + darker placket + edge stitching) running hem
-> through the stand collar; STAND COLLAR raised look via texture
shading (base seam ring + cast-shadow band under the collar + brighter
gradient toward the collar rim — geometry stays stock); ribbed knit cuff
+ hem bands with border stitch lines; stripe edge piping.
* Required bottoms/footwear practices adopted for this top
(blender_author_denim_pants.py): boundary WELD of coincident
segment-seam rings before offsetting (elbow/shoulder/chest seams offset
as one surface, no gap rings) and open-rim FLATTENING — the segment
splitter leaves ~4.5-7.6 cm jagged teeth at the torso-bottom hem ring,
and the vert-threshold wrist cut leaves jagged sleeve ends. The hem ring
is pulled UP to its own valley (clean elastic hem, no invented
coverage) with a post-solidify residue clamp; the wrist rings are pulled
OUT to the nominal cut plane (clean cuff edge over real forearm skin).
* LEFT-BREAST logo patch (logo-capable): the base UV2 chest box shifts
off-centre — the zip owns the centre line — using the outerwear ratios.
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_track_jacket.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/track_jacket \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-keep 0.90]
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 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
"""
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)
def log(msg):
print(f"[track-jacket] {msg}")
# --------------------------------------------------------------------------
# Parameters — track_jacket. A sport-top variant should only touch this block.
# --------------------------------------------------------------------------
GARMENT_ID = "track_jacket"
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.018 # sport shell drape: between the 16 mm hoodie and the
# 22 mm casual jacket (per-body construction
# guarantees body clearance either way)
CLOTH_THICKNESS_M = 0.005 # tricot/poly shell weight
WRIST_KEEP_FRAC = 0.90 # fraction of the lowerarm kept (hands show)
CUFF_BAND_FRAC = 0.18 # last fraction of the KEPT forearm = knit cuff (A)
WELD_DIST = 5e-4 # boundary-ring weld tolerance (0.5 mm)
TEX_SIZE = 1024 # albedo + mask resolution (zip teeth need > 512)
# Stand collar (outerwear-calibrated: taller + wider than the tee band).
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below the neck head
COLLAR_X_MULT = 1.15 # slightly wider than the tee band
# Sleeve stripes — metric widths on average_m, scaled by shoulder ratio.
STRIPE_GAP_HALF_M = 0.006 # half-gap between the two stripes (about the top line)
STRIPE_W_M = 0.018 # width of each stripe
STRIPE_EDGE_W_M = 0.0035 # painted piping line at each stripe border
# Front zip (hem -> through the collar), slimmer than jacket_modern's placket.
ZIP_HALF_FRAC = 0.022 / base._REF_SHOULDER_X # placket half-width / shoulder |x|
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 height as fraction of the garment span (collar_z_min - hem plane).
HEM_BAND_FRAC = 0.07
RIB_PERIOD_M = 0.009 # knit rib period on cuff/hem bands
LINE_W_M = 0.004 # seam / border stitch line half-width
# Collar raised-look shading.
SHADOW_H_FRAC = 0.30 # under-collar cast-shadow band height, x neck_len
COLLAR_GRAD = 0.06 # extra luma toward the collar rim (raised read)
COLLAR_REACH_MULT = 1.35 # seam/shadow lateral reach, x collar_x_abs
# Left-breast logo patch (outerwear ratios; centre line belongs to the zip).
CHEST_X_LO_FRAC = 0.035 / base._REF_SHOULDER_X
CHEST_X_HI_FRAC = 0.115 / base._REF_SHOULDER_X
CHEST_Z_LO_FRAC = (1.30 - base._REF_SPINE_LO_Z) / (base._REF_NECK_Z - base._REF_SPINE_LO_Z)
CHEST_Z_HI_FRAC = (1.42 - base._REF_SPINE_LO_Z) / (base._REF_NECK_Z - base._REF_SPINE_LO_Z)
# Painted-albedo luma palette (toon_garment recolors by luma: tint*(luma*1.5+0.2)).
BASE_LUMA = 0.55 # tricot shell
STRIPE_LUMA = 0.62 # stripes slightly lighter (read under same-tint too)
STRIPE_EDGE_LUMA = 0.36 # stripe piping
COLLAR_LUMA = 0.57 # stand collar band
COLLAR_SEAM_LUMA = 0.30 # collar base seam ring
COLLAR_SHADOW_LUMA = 0.42 # cast shadow just under the collar base
PLACKET_LUMA = 0.48 # zip placket band
ZIP_EDGE_LUMA = 0.34 # placket edge stitching
TEETH_LUMA = 0.88 # bright zip teeth dashes
TEETH_GAP_LUMA = 0.34 # dark tape between dashes
BAND_LUMA = 0.46 # ribbed cuff + hem bands
BAND_LINE_LUMA = 0.32 # band border stitch line
RIB_DELTA = 0.035 # knit rib darkening within bands
ALBEDO_NOISE = 0.02 # woven jitter
FABRIC_HUE = np.array([0.97, 0.98, 1.03], dtype=np.float32) # faint cool cast
NOISE_SEED = 2094
# --------------------------------------------------------------------------
# Thresholds — base derivation + track-jacket extras from the same armature
# --------------------------------------------------------------------------
def track_thresholds(armature, wrist_keep):
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")
la_l = bones.get("lowerarm_l")
spine01 = bones.get("spine_01")
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 = base._REF_SHOULDER_X
thr["sh"] = shoulder_x / base._REF_SHOULDER_X
# Taller, wider stand 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["neck_len"] = neck_len
else:
thr["neck_len"] = base._REF_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 + knit cuff start 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 + wrist_keep * (tail_x - head_x)
band_x = head_x + (wrist_keep - 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
# Per-|x| arm axis (shoulder -> elbow -> wrist) for stripe placement —
# the denim per-z leg-axis technique rotated onto the arms. The rig is
# x-mirrored, so the left-arm landmarks serve both sides via |x|.
if ua_l and la_l:
pts = [ua_l.head_local, la_l.head_local, la_l.tail_local]
order = np.argsort([abs(p.x) for p in pts])
thr["arm_x"] = np.array([abs(pts[i].x) for i in order])
thr["arm_y"] = np.array([pts[i].y for i in order])
thr["arm_z"] = np.array([pts[i].z for i in order])
else:
log("WARNING: arm landmark bones missing — stripes disabled")
thr["arm_x"] = None
# 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"track thresholds: collar z>={thr['collar_z_min']:.3f} "
f"|x|<{thr['collar_x_abs']:.3f} zip half {thr['zip_half_x']:.3f} "
f"cuff |x|>={thr['cuff_x_abs']:.3f} sh {thr['sh']:.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
# --------------------------------------------------------------------------
# Geometry: weld + wrist cut + open-rim flattening (denim required practices)
# --------------------------------------------------------------------------
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 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")
def flatten_open_rims(shell, thr):
"""Pull the open boundary rings onto clean planes (pre-offset).
The segment splitter cuts along weight thresholds, so the torso-bottom
hem ring is a jagged ring of teeth (the denim script measured ~4.5-7.6 cm
across bodies), and the vert-threshold wrist cut leaves jagged sleeve
ends. Three rims are cleaned:
* WRIST rings (|x| >= sleeve_x_abs, per side): pulled OUT to the
nominal bone-plane cut — a clean vertical cuff edge; the forearm
skin continues well past the plane, so no clip risk is invented.
* HEM ring (bottom of the remaining boundary): pulled UP to its own
valley (the shallowest notch) — a clean straight elastic hem without
inventing coverage below the authored shell.
* NECK ring (top): left natural — every wave-1 top ships the natural
neckline boundary and the collar band owns that edge visually.
Only boundary verts move; weights and loop UVs ride along. Returns the
hem plane z (needed for the hem band paint + post-solidify clamp).
"""
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)
sleeve_x = thr["sleeve_x_abs"]
cut_by_sign = {sign: cut_x for sign, cut_x in thr.get("wrist_cut_planes", [])}
wrist = [i for i in boundary if abs(bm.verts[i].co.x) >= sleeve_x]
torso = [i for i in boundary if abs(bm.verts[i].co.x) < sleeve_x]
zs = [bm.verts[i].co.z for i in torso]
z_mid = (min(zs) + max(zs)) / 2.0
hem = [i for i in torso if bm.verts[i].co.z <= z_mid]
neck = [i for i in torso if bm.verts[i].co.z > z_mid]
moved_wrist = 0
for i in wrist:
v = bm.verts[i]
sign = 1 if v.co.x >= 0.0 else -1
if sign in cut_by_sign:
v.co.x = cut_by_sign[sign]
moved_wrist += 1
hem_lo = min(bm.verts[i].co.z for i in hem)
hem_plane = max(bm.verts[i].co.z for i in hem) # the valley (shallowest notch)
for i in hem:
bm.verts[i].co.z = hem_plane
bm.to_mesh(me)
bm.free()
me.update()
log(f"flattened open rims: {moved_wrist} wrist verts -> nominal cut planes; "
f"hem ring ({len(hem)} verts, teeth {hem_plane - hem_lo:.3f} m) -> "
f"z={hem_plane:.3f}; neck ring left natural ({len(neck)} verts)")
return hem_plane
def clamp_hem_residue(shell, hem_plane):
"""Post-solidify safety clamp: interior tooth verts still below the hem
plane (multi-triangle teeth) get squashed onto it."""
me = shell.data
n = 0
for v in me.vertices:
if v.co.z < hem_plane:
v.co.z = hem_plane
n += 1
me.update()
if n:
log(f"clamped {n} residual hem verts -> {hem_plane:.3f}")
# --------------------------------------------------------------------------
# Track feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _paint_texels(X, Y, Z, noise, thr, hem_plane, hem_top):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions.
ONE feature evaluation drives both outputs (denim practice), so the
painted albedo and the region mask always agree.
Region priority: cuff/hem band (A) > sleeve stripes (B) > collar (R) > body (G).
"""
n = X.shape[0]
ax = np.abs(X)
front = Y * base.FRONT_Y_SIGN > 0.004
sh = thr["sh"]
on_sleeve = ax >= thr["sleeve_x_abs"]
in_cuff = ax >= thr["cuff_x_abs"]
in_hem = (~on_sleeve) & (Z <= hem_top)
czmin = thr["collar_z_min"]
in_collar = (~on_sleeve) & (Z >= czmin) & (ax < thr["collar_x_abs"])
# Sleeve stripes: arc distance from the top line of the per-|x| arm axis.
if thr.get("arm_x") is not None:
cy = np.interp(ax, thr["arm_x"], thr["arm_y"])
cz = np.interp(ax, thr["arm_x"], thr["arm_z"])
dy = Y - cy
dz = Z - cz
r = np.hypot(dy, dz) + 1e-9
arc = np.arctan2(np.abs(dy), dz) * r # 0 on the sleeve top line
g0 = STRIPE_GAP_HALF_M * sh
w = STRIPE_W_M * sh
stripe_zone = on_sleeve & ~in_cuff
stripe = stripe_zone & (arc >= g0) & (arc <= g0 + w)
ew = STRIPE_EDGE_W_M * sh
stripe_edge = stripe_zone & (
(np.abs(arc - g0) < ew) | (np.abs(arc - (g0 + w)) < ew))
else:
arc = np.zeros(n, dtype=np.float32)
stripe = np.zeros(n, dtype=bool)
stripe_edge = np.zeros(n, dtype=bool)
# --- region mask (collar=R, body=G, stripes=B, cuffs/hem=A) -------------
mask = np.zeros((n, 4), dtype=np.float32)
is_a = in_cuff | in_hem
is_b = stripe & ~is_a
is_r = in_collar & ~is_a & ~is_b
is_g = ~(is_a | is_b | is_r)
mask[is_a, 3] = 1.0
mask[is_b, 2] = 1.0
mask[is_r, 0] = 1.0
mask[is_g, 1] = 1.0
# --- painted luma detail -------------------------------------------------
v = np.full(n, BASE_LUMA, dtype=np.float32)
lw = LINE_W_M * sh
# Stripes + piping.
v[stripe] = STRIPE_LUMA
v[stripe_edge] = STRIPE_EDGE_LUMA
# Stand collar raised look: band luma + brighter gradient toward the rim,
# cast-shadow band + seam ring at the collar base (texture-carried depth).
reach = thr["collar_x_abs"] * COLLAR_REACH_MULT
v[in_collar] = COLLAR_LUMA
grad = np.clip((Z - czmin) / max(thr["neck_len"], 1e-6), 0.0, 1.0)
v[in_collar] += COLLAR_GRAD * grad[in_collar]
shadow_h = SHADOW_H_FRAC * thr["neck_len"]
under = (~in_collar) & (~on_sleeve) & (ax < reach) \
& (Z < czmin) & (Z >= czmin - shadow_h)
t = np.clip((czmin - Z) / max(shadow_h, 1e-6), 0.0, 1.0)
v[under] = COLLAR_SHADOW_LUMA \
+ (BASE_LUMA - COLLAR_SHADOW_LUMA) * t[under]
seam = (~on_sleeve) & (np.abs(Z - czmin) < lw) & (ax < reach)
v[seam] = COLLAR_SEAM_LUMA
# Ribbed cuff + hem bands: band luma, knit ribs, border stitch lines.
band = in_cuff | in_hem
v[band] = BAND_LUMA
cuff_rib = in_cuff & (np.mod(arc, RIB_PERIOD_M) < RIB_PERIOD_M * 0.5)
hem_rib = in_hem & (np.mod(X, RIB_PERIOD_M) < RIB_PERIOD_M * 0.5)
v[cuff_rib | hem_rib] -= RIB_DELTA
v[np.abs(ax - thr["cuff_x_abs"]) < lw] = BAND_LINE_LUMA
v[(~on_sleeve) & (np.abs(Z - hem_top) < lw)] = BAND_LINE_LUMA
# Front zip: placket band + edge stitching + dashed teeth, hem -> through
# the collar (paint overrides bands/collar luma; regions stay untouched).
zh = thr["zip_half_x"]
v[front & (ax < zh)] = PLACKET_LUMA
v[front & (np.abs(ax - zh) < lw * 0.7)] = ZIP_EDGE_LUMA
teeth = front & (ax < TEETH_HALF_W)
dash = np.mod(Z, TEETH_PERIOD) < TEETH_DUTY
v[teeth & dash] = TEETH_LUMA
v[teeth & ~dash] = TEETH_GAP_LUMA
# --- albedo --------------------------------------------------------------
lum = np.clip(v + noise, 0.0, 1.0)
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = np.clip(lum * FABRIC_HUE[c], 0.0, 1.0)
alb[:, 3] = 1.0
return alb, mask
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, thr,
hem_plane, hem_top, W, H):
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
evaluate the track feature 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],
thr, hem_plane, hem_top)
alb_buf[ysin, xsin] = alb
mask_buf[ysin, xsin] = mask
def paint_albedo_and_mask(shell, thr, hem_plane, hem_top, out_dir, 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] = np.clip(
(BASE_LUMA + noise_buf) * FABRIC_HUE[c], 0.0, 1.0)
alb_buf[:, :, 3] = 1.0
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
mask_buf[:, :, 1] = 1.0 # background = body 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]),
thr, hem_plane, hem_top, W, H)
tri_count += 1
bm.free()
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
# Honest region tally over the whole mask (texel counts, background excl.
# impossible — background is body green by design).
tot = W * H
counts = {
"collar(R)": int((mask_buf[:, :, 0] > 0.5).sum()),
"body(G)": int((mask_buf[:, :, 1] > 0.5).sum()),
"stripes(B)": int((mask_buf[:, :, 2] > 0.5).sum()),
"cuff/hem(A)": int((mask_buf[:, :, 3] > 0.5).sum()),
}
log("mask texels: " + " ".join(
f"{k}={v} ({100.0 * v / tot:.1f}%)" for k, v in counts.items()))
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
_save(mask_buf, f"{GARMENT_ID}_mask_{body}",
os.path.join(out_dir, f"{body}_mask.png"))
albedo_img = _save(alb_buf, f"{GARMENT_ID}_albedo_{body}",
os.path.join(out_dir, f"{body}_base_albedo.png"))
log(f"saved mask -> {body}_mask.png")
log(f"saved albedo -> {body}_base_albedo.png")
# Repoint the image at the shared sidecar name before export: the glTF
# exporter names the embedded image after the file basename, and Godot
# extracts it as <glb>_<imagename>.png -> <body>_base_albedo.png (tshirt
# convention). The shared file is re-pointed to average_m's at the end.
albedo_img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
albedo_img.save()
return albedo_img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_track_jacket(body_dir, out_dir, body, offset, wrist_keep):
base.clear_scene()
base.COVERED_SEGMENTS = SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
thr = track_thresholds(armature, wrist_keep)
weld_boundaries(shell)
wrist_cut(shell, thr)
hem_plane = flatten_open_rims(shell, thr)
base.offset_outward(shell, offset)
base.solidify(shell, CLOTH_THICKNESS_M)
clamp_hem_residue(shell, hem_plane)
hem_top = hem_plane + HEM_BAND_FRAC * (thr["collar_z_min"] - hem_plane)
log(f"hem band: z {hem_plane:.3f}..{hem_top:.3f}")
base.author_logo_uv(shell, thr) # UV2 before bake (bake reads UV0 directly)
albedo_img = paint_albedo_and_mask(shell, thr, hem_plane, hem_top,
out_dir, body)
base.assign_fabric_material(shell, albedo_img)
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-keep F]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = OFFSET_M
wrist_keep = WRIST_KEEP_FRAC
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--wrist-keep" in argv:
wrist_keep = float(argv[argv.index("--wrist-keep") + 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"track-jacket per-body mode: {len(bodies)} bodies, "
f"offset {offset*1000:.0f} mm, wrist keep {wrist_keep:.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_track_jacket(body_dir, out_dir, body, offset, wrist_keep)
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()