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