Files
settled-reach/tooling/scripts/blender/blender_author_track_jacket.py
T
jpmschweitzerandClaude Opus 5.5 c597ec9131 docs(tooling): T-1253 — sweep the live references to retired tool paths
A script scanned every tracked doc, rule, skill, agent, hook and source file
for tooling/ paths that no longer exist, skipping historical records (sprints,
discussions, workshops, governance, generated wiki pages). It found 62. The
ones that tell a reader what to RUN now name the reach verb:

- The atlas skill still sent agents to tooling/atlas, atlas-verify,
  atlas-update-field and atlas-commit-and-sync — about forty lines, all
  retired in T-1285. They now name the `reach atlas` verbs, and the skill
  records that commit-and-sync STAGES by default (--commit to commit) and
  takes --corridor as an option.
- The clerk agent named tooling/clerk-review (now `reach dev clerk`). The Si
  and clerk briefings sent those agents to the retired tooling/db/decision
  and sqlite-query CLIs and to decisions/*.md paths that moved to
  governance/ in the pql migration. They now name pql.
- The ticket-cli rule documented `pql decisions read`, which does not exist;
  `show` already includes the body.
- The culture authoring guide and the RON sources name
  `reach validate ron`, with the same arguments as before.
- The 41 Blender payloads' usage lines ran the retired tooling/blender
  wrapper, and the docstrings still cited pre-carve-out paths. They now read
  `reach blender run <payload>`.
- Doc comments in server/, client/, wiki TOMLs and the domain modules.

What is left is deliberate: "Formerly …" provenance, dated plans and findings
docs, the retired-pipeline doc, and a build-artefact path.

project.yaml 0.4.14 (mirrored to the client). Comment-only, but four touched
files are in the canvas-version registry (trait_catalog_reader.rs, since
T-1289, canvas_sources.py itself, and two client files). The gate is
path-based and has no override. The previous push was rejected on exactly
this.

Three of the edits are stamped ledger sources, so systems.db is regenerated
and the stamp is fresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:13:58 +02:00

654 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):
reach blender run blender_author_track_jacket \
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()