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>
655 lines
27 KiB
Python
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()
|