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>
590 lines
23 KiB
Python
590 lines
23 KiB
Python
"""
|
|
blender_author_hoodie.py (T-1089 — hoodie_modern, per-body offset-shell)
|
|
|
|
Hooded-top companion to blender_author_offset_shell.py: reuses the base
|
|
module's segment join / bone-ratio thresholds / offset / solidify / logo-UV2 /
|
|
export machinery and layers the hoodie-specific geometry + texture identity on
|
|
top. Everything spatial is derived from bone landmarks (ratios of neck_01
|
|
length, shoulder |x|, spine span) so the same parameters produce a
|
|
proportionally identical garment on all 11 bodies. The parameter block below
|
|
is the reusable seam for the rest of the hooded/long-sleeve family
|
|
(track_jacket, sweater_modern): import this module and override.
|
|
|
|
Hoodie deltas over the base t-shirt shell:
|
|
* covers torso + torso_upper + FULL arms (long sleeves, wrist-cut on the
|
|
lowerarm bone instead of the upperarm short-sleeve cut)
|
|
* HOOD DOWN — a rolled collar bulk ring: collar-band verts are inflated
|
|
radially away from the neck axis (back-biased, slight upward lift) before
|
|
solidify, so the roll gets real thickness and caps into a ring
|
|
* looser standoff (16 mm vs the 12 mm per-body tee) + thicker cloth (6 mm)
|
|
* painted albedo identity (texture-carried, per body because UV0 face
|
|
classification is per body): kangaroo pocket fill + stitch outline,
|
|
drawstrings, ribbed hem + cuff bands — all painted as LUMINANCE detail so
|
|
the luma-preserving toon_garment recolor keeps them under any tint
|
|
* region mask: R = collar/hood roll (asymmetric drop — drapes lower on the
|
|
back), G = body, B = sleeves + kangaroo pocket
|
|
* logo-capable chest via the base UV2 channel (unchanged)
|
|
|
|
PER-BODY ONLY (Q-060: offset-shells are authored per body, never SD-fit):
|
|
|
|
reach blender run blender_author_hoodie \
|
|
client/assets/characters/bodies client/assets/characters/clothing/hoodie_modern \
|
|
[--bodies a,b,c] [--offset M]
|
|
|
|
Writes per body: <out_dir>/<body>.glb, <body>_mask.png, <body>_base_albedo.png
|
|
Plus fallbacks: <out_dir>/base_albedo.png + reference_mask.png (= average_m's).
|
|
|
|
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
|
"""
|
|
|
|
import importlib.util
|
|
import math
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import bpy
|
|
import bmesh
|
|
import numpy as np
|
|
from mathutils import Vector
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
def _load_base():
|
|
spec = importlib.util.spec_from_file_location(
|
|
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
base = _load_base()
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Hoodie parameters (the reusable seam — override for track_jacket/sweater)
|
|
# --------------------------------------------------------------------------
|
|
|
|
GARMENT_ID = "hoodie_modern"
|
|
|
|
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.016 # loose standoff (per-body construction guarantees it)
|
|
THICKNESS_M = 0.006 # fleece-weight cloth
|
|
WRIST_FRAC = 0.92 # fraction of the lowerarm kept (sleeve ends at wrist)
|
|
|
|
# Hood-down roll: fractions of neck_01 length unless noted.
|
|
ROLL_OUT_FRAC = 0.40 # radial bulge magnitude
|
|
ROLL_LIFT_FRAC = 0.14 # upward lift at the rim
|
|
ROLL_Z_START_FRAC = 0.45 # roll influence starts this far below collar_z_min
|
|
ROLL_REACH_COLLAR_X = 1.7 # candidate radius around the neck axis (x collar_x_abs)
|
|
ROLL_FRONT_GAIN = 0.55 # bulge scale at the front...
|
|
ROLL_BACK_GAIN = 1.10 # ...and at the back (hood mass hangs behind)
|
|
ROLL_EXPONENT = 1.7 # falloff sharpness toward the rim
|
|
|
|
# Region mask R (roll) band: drop below collar_z_min, x neck_len, per side.
|
|
R_Z_DROP_FRONT = 0.15
|
|
R_Z_DROP_BACK = 0.55
|
|
|
|
# Kangaroo pocket: z as fractions of the waistband-top -> chest_z_lo span
|
|
# (sits ABOVE the ribbed hem band), x as fractions of shoulder |x|.
|
|
POCKET_Z0_FRAC = 0.06
|
|
POCKET_Z1_FRAC = 0.88
|
|
POCKET_WB_FRAC = 0.56 # bottom half-width
|
|
POCKET_WT_FRAC = 0.30 # top half-width (diagonal hand openings)
|
|
POCKET_FILL_MULT = 0.95 # subtle patch shading
|
|
POCKET_LINE_MULT = 0.70 # stitch outline darkening
|
|
POCKET_LINE_PX = 2 # outline thickness (erosion iterations)
|
|
|
|
# Drawstrings (front only): x offset / half-width as fractions of collar_x_abs,
|
|
# z as fractions of neck_len.
|
|
STRING_X_FRAC = 0.30
|
|
STRING_HALF_W_M = 0.005
|
|
STRING_TOP_DROP_FRAC = 0.10
|
|
STRING_LEN_FRAC = 0.75
|
|
STRING_MULT = 0.50
|
|
|
|
# Ribbed bands: hem (x spine span) and cuffs (x lowerarm length). The cuff is
|
|
# anchored to the mesh's ACTUAL post-cut reach, not the nominal wrist plane —
|
|
# the vert-threshold cut leaves a jagged face boundary that stops 1-3 cm short
|
|
# of the plane, so a nominal-anchored band would mostly land on deleted faces.
|
|
HEM_BAND_FRAC = 0.08
|
|
CUFF_LEN_FRAC = 0.16
|
|
BAND_MULT = 0.85
|
|
BAND_LINE_MULT = 0.72
|
|
BAND_LINE_M = 0.004
|
|
|
|
# Muted street-tone fabric (luma drives the toon_garment recolor).
|
|
FABRIC_RGB = (0.55, 0.56, 0.58)
|
|
FABRIC_NOISE = 0.035
|
|
ALBEDO_SEED = 1091
|
|
|
|
|
|
def log(msg):
|
|
print(f"[hoodie] {msg}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Landmarks beyond the base thresholds
|
|
# --------------------------------------------------------------------------
|
|
|
|
def derive_landmarks(armature, thr):
|
|
"""Hoodie-specific bone landmarks (all in body-local metres)."""
|
|
bones = armature.data.bones
|
|
neck = bones.get("neck_01")
|
|
la_l = bones.get("lowerarm_l")
|
|
la_r = bones.get("lowerarm_r")
|
|
if not all([neck, la_l, la_r]):
|
|
raise RuntimeError("landmark bones missing (neck_01 / lowerarm_l/r)")
|
|
neck_len = neck.tail_local.z - neck.head_local.z
|
|
lm = {
|
|
"neck_y": neck.head_local.y,
|
|
"neck_len": neck_len,
|
|
"roll_out": ROLL_OUT_FRAC * neck_len,
|
|
"roll_lift": ROLL_LIFT_FRAC * neck_len,
|
|
"roll_reach": ROLL_REACH_COLLAR_X * thr["collar_x_abs"],
|
|
# wrist cut thresholds per side: (sign, x threshold)
|
|
"wrist_cuts": [],
|
|
"lowerarm_len": 0.0,
|
|
}
|
|
for b, sign in [(la_l, +1), (la_r, -1)]:
|
|
head_x, tail_x = b.head_local.x, b.tail_local.x
|
|
thr_x = head_x + WRIST_FRAC * (tail_x - head_x)
|
|
lm["wrist_cuts"].append((sign, thr_x))
|
|
lm["lowerarm_len"] = abs(tail_x - head_x)
|
|
log(f"landmarks: neck_len {neck_len:.4f} roll_out {lm['roll_out']*1000:.0f}mm "
|
|
f"reach {lm['roll_reach']:.3f} wrist cuts "
|
|
+ " ".join(f"{s:+d}@{t:.3f}" for s, t in lm["wrist_cuts"]))
|
|
return lm
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Geometry: wrist cut + hood roll
|
|
# --------------------------------------------------------------------------
|
|
|
|
def wrist_cut(shell, lm):
|
|
"""Trim the sleeve tubes at the wrist plane (same pattern as base.sleeve_cut,
|
|
but on the lowerarm bone so the sleeves stay full length)."""
|
|
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 lm["wrist_cuts"]:
|
|
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 hood_roll(shell, thr, lm):
|
|
"""Inflate collar-band verts radially away from the neck axis to read as a
|
|
rolled-down hood: back-biased bulge + slight rim lift. Runs after the
|
|
outward offset and before solidify (the roll then gets cloth thickness)."""
|
|
z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * lm["neck_len"]
|
|
reach = lm["roll_reach"]
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
|
|
candidates = []
|
|
z_rim = z_start
|
|
for v in bm.verts:
|
|
if v.co.z < z_start:
|
|
continue
|
|
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
|
|
if hd > reach or hd < 1e-6:
|
|
continue
|
|
candidates.append((v, hd))
|
|
z_rim = max(z_rim, v.co.z)
|
|
if z_rim <= z_start or not candidates:
|
|
log("WARNING: no hood-roll candidates found — roll skipped")
|
|
bm.free()
|
|
return
|
|
|
|
moved = 0
|
|
for v, hd in candidates:
|
|
t = (v.co.z - z_start) / (z_rim - z_start)
|
|
w = max(0.0, min(1.0, t)) ** ROLL_EXPONENT
|
|
if w <= 0.0:
|
|
continue
|
|
dir_h = Vector((v.co.x, v.co.y - lm["neck_y"], 0.0)) / hd
|
|
backness = 0.5 * (1.0 + dir_h.y * -base.FRONT_Y_SIGN) # +Y = back
|
|
gain = ROLL_FRONT_GAIN + (ROLL_BACK_GAIN - ROLL_FRONT_GAIN) * backness
|
|
v.co += dir_h * (lm["roll_out"] * w * gain)
|
|
v.co.z += lm["roll_lift"] * w
|
|
moved += 1
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
log(f"hood roll: {moved} verts inflated (rim z {z_rim:.3f}, "
|
|
f"start z {z_start:.3f})")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Paint maps: rasterize body-space (x, z) into UV0 texel space
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _raster_tri_attr(maps, a, b, c, xs, zs, is_front, is_back, W, H):
|
|
"""Barycentric rasterization interpolating body-space x/z per texel."""
|
|
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, px_x = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
|
px = px_x + 0.5
|
|
py = ys + 0.5
|
|
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
|
|
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
|
|
w2 = 1.0 - w0 - w1
|
|
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
|
if not inside.any():
|
|
return
|
|
x_val = w0 * xs[0] + w1 * xs[1] + w2 * xs[2]
|
|
z_val = w0 * zs[0] + w1 * zs[1] + w2 * zs[2]
|
|
sl = (slice(miny, maxy + 1), slice(minx, maxx + 1))
|
|
maps["X"][sl][inside] = x_val[inside]
|
|
maps["Z"][sl][inside] = z_val[inside]
|
|
maps["valid"][sl][inside] = True
|
|
if is_front:
|
|
maps["front"][sl][inside] = True
|
|
if is_back:
|
|
maps["back"][sl][inside] = True
|
|
|
|
|
|
def bake_paint_maps(shell, W, H):
|
|
"""Rasterize the shell's UV0 layout into per-texel body-space X/Z maps,
|
|
with front/back facing flags (front = -Y on this rig)."""
|
|
maps = {
|
|
"X": np.zeros((H, W), dtype=np.float32),
|
|
"Z": np.zeros((H, W), dtype=np.float32),
|
|
"valid": np.zeros((H, W), dtype=bool),
|
|
"front": np.zeros((H, W), dtype=bool),
|
|
"back": np.zeros((H, W), dtype=bool),
|
|
}
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
bm.faces.ensure_lookup_table()
|
|
bm.normal_update()
|
|
uv_layer = bm.loops.layers.uv.active
|
|
if uv_layer is None:
|
|
raise RuntimeError("no active UV layer for paint-map bake")
|
|
for face in bm.faces:
|
|
n_front = face.normal.y * base.FRONT_Y_SIGN
|
|
c_front = face.calc_center_median().y * base.FRONT_Y_SIGN
|
|
is_front = n_front > 0.15 and c_front > 0.0
|
|
is_back = n_front < -0.15 and c_front < 0.0
|
|
loops = face.loops[:]
|
|
uvs = [lp[uv_layer].uv.copy() for lp in loops]
|
|
cos = [lp.vert.co for lp in loops]
|
|
for i in range(1, len(uvs) - 1):
|
|
_raster_tri_attr(
|
|
maps, uvs[0], uvs[i], uvs[i + 1],
|
|
(cos[0].x, cos[i].x, cos[i + 1].x),
|
|
(cos[0].z, cos[i].z, cos[i + 1].z),
|
|
is_front, is_back, W, H)
|
|
bm.free()
|
|
overlap = int((maps["front"] & maps["back"]).sum())
|
|
log(f"paint maps: {int(maps['valid'].sum())} texels "
|
|
f"(front {int(maps['front'].sum())}, back {int(maps['back'].sum())}, "
|
|
f"front/back UV overlap {overlap})")
|
|
dump = os.environ.get("HOODIE_DEBUG_MAPS", "")
|
|
if dump:
|
|
np.savez_compressed(dump, **maps)
|
|
log(f"debug maps dumped -> {dump}")
|
|
return maps
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Painted albedo (texture-carried modern identity)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def derive_paint_params(shell, thr, lm):
|
|
"""Body-space paint geometry, derived from thresholds + final shell mesh."""
|
|
sleeve_x = thr["sleeve_x_abs"]
|
|
hem_z = min((v.co.z for v in shell.data.vertices
|
|
if abs(v.co.x) < sleeve_x), default=1.0)
|
|
# Actual sleeve reach from the final mesh (jagged post-cut boundary).
|
|
wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=sleeve_x)
|
|
chest_lo = thr["chest_z"][0]
|
|
hem_band = HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
|
|
hem_top = hem_z + hem_band
|
|
span = chest_lo - hem_top
|
|
shoulder_x = thr["chest_x"][1] / base.CHEST_X_FRAC # invert base ratio
|
|
p = {
|
|
"hem_z": hem_z,
|
|
"hem_band": hem_band,
|
|
"pocket_z0": hem_top + POCKET_Z0_FRAC * span,
|
|
"pocket_z1": hem_top + POCKET_Z1_FRAC * span,
|
|
"pocket_wb": POCKET_WB_FRAC * shoulder_x,
|
|
"pocket_wt": POCKET_WT_FRAC * shoulder_x,
|
|
"string_x": STRING_X_FRAC * thr["collar_x_abs"],
|
|
"string_z_top": thr["collar_z_min"] - STRING_TOP_DROP_FRAC * lm["neck_len"],
|
|
"string_len": STRING_LEN_FRAC * lm["neck_len"],
|
|
"cuff_len": CUFF_LEN_FRAC * lm["lowerarm_len"],
|
|
"wrist_x": wrist_x,
|
|
"sleeve_x": sleeve_x,
|
|
}
|
|
log(f"paint params: hem {hem_z:.3f} pocket z ({p['pocket_z0']:.3f},"
|
|
f"{p['pocket_z1']:.3f}) w ({p['pocket_wt']:.3f}->{p['pocket_wb']:.3f})")
|
|
return p
|
|
|
|
|
|
def _erode(m, iters):
|
|
for _ in range(iters):
|
|
m = (m
|
|
& np.roll(m, 1, 0) & np.roll(m, -1, 0)
|
|
& np.roll(m, 1, 1) & np.roll(m, -1, 1))
|
|
return m
|
|
|
|
|
|
def pocket_mask(maps, p):
|
|
"""Kangaroo-pocket texel mask: front-only trapezoid with diagonal sides."""
|
|
X, Z = maps["X"], maps["Z"]
|
|
paintable = maps["front"] & ~maps["back"]
|
|
z0, z1 = p["pocket_z0"], p["pocket_z1"]
|
|
if z1 <= z0:
|
|
return np.zeros_like(paintable)
|
|
t = np.clip((z1 - Z) / (z1 - z0), 0.0, 1.0) # 1 at bottom, 0 at top
|
|
half_w = p["pocket_wt"] + (p["pocket_wb"] - p["pocket_wt"]) * t
|
|
inside = paintable & (Z >= z0) & (Z <= z1) & (np.abs(X) <= half_w)
|
|
log(f"pocket mask: {int(inside.sum())} texels")
|
|
return inside
|
|
|
|
|
|
def make_painted_albedo(maps, pocket_px, p, out_dir, body):
|
|
"""Flat street-tone fabric + painted luminance detail; returns bpy image."""
|
|
W = H = base.ALBEDO_SIZE
|
|
rng = np.random.default_rng(ALBEDO_SEED)
|
|
fabric = np.array(FABRIC_RGB, dtype=np.float32)
|
|
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE
|
|
rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
|
|
|
X, Z = maps["X"], maps["Z"]
|
|
valid = maps["valid"]
|
|
front_only = maps["front"] & ~maps["back"]
|
|
|
|
# Ribbed hem band (all around) + top stitch line.
|
|
hem_top = p["hem_z"] + p["hem_band"]
|
|
band = valid & (Z <= hem_top) & (np.abs(X) <= p["sleeve_x"])
|
|
rgb[band] *= BAND_MULT
|
|
line = valid & (np.abs(Z - hem_top) <= BAND_LINE_M) & (np.abs(X) <= p["sleeve_x"])
|
|
rgb[line] *= BAND_LINE_MULT
|
|
|
|
# Ribbed cuffs (all around) + inner stitch line.
|
|
cuff_x0 = p["wrist_x"] - p["cuff_len"]
|
|
cuff = valid & (np.abs(X) >= cuff_x0)
|
|
rgb[cuff] *= BAND_MULT
|
|
cline = valid & (np.abs(np.abs(X) - cuff_x0) <= BAND_LINE_M)
|
|
rgb[cline] *= BAND_LINE_MULT
|
|
log(f"paint counts: hem {int(band.sum())} hemline {int(line.sum())} "
|
|
f"cuff {int(cuff.sum())} cuffline {int(cline.sum())} "
|
|
f"(hem_top {hem_top:.3f}, cuff_x0 {cuff_x0:.3f})")
|
|
zs = Z[valid]
|
|
xs_v = np.abs(X[valid])
|
|
log(f"map ranges: Z [{zs.min():.3f},{zs.max():.3f}] "
|
|
f"|X| [{xs_v.min():.3f},{xs_v.max():.3f}] "
|
|
f"Z<=hem_top {int((zs <= hem_top).sum())} "
|
|
f"|X|>=cuff_x0 {int((xs_v >= cuff_x0).sum())}")
|
|
|
|
# Kangaroo pocket: subtle fill + dark stitch outline.
|
|
rgb[pocket_px] *= POCKET_FILL_MULT
|
|
outline = pocket_px & ~_erode(pocket_px, POCKET_LINE_PX)
|
|
rgb[outline] *= POCKET_LINE_MULT
|
|
|
|
# Drawstrings (front only, hanging from the collar).
|
|
z_top = p["string_z_top"]
|
|
z_bot = z_top - p["string_len"]
|
|
for sx in (+p["string_x"], -p["string_x"]):
|
|
s = front_only & (np.abs(X - sx) <= STRING_HALF_W_M) \
|
|
& (Z >= z_bot) & (Z <= z_top)
|
|
rgb[s] *= STRING_MULT
|
|
|
|
rgba = np.concatenate([rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
|
|
img = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False)
|
|
img.pixels.foreach_set(rgba.reshape(-1))
|
|
img.update()
|
|
sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
|
|
img.filepath_raw = sidecar
|
|
img.file_format = 'PNG'
|
|
img.save()
|
|
log(f"painted albedo -> {sidecar}")
|
|
# The glTF exporter names the embedded image after the file basename, and
|
|
# the Godot import EXTRACTS it as <glb>_<imagename>.png. Point the image at
|
|
# the shared base_albedo.png so the extraction lands exactly on the sidecar
|
|
# name above (<body>_base_albedo.png, same pixels — tshirt convention),
|
|
# instead of a doubled <body>_<body>_base_albedo.png. The shared file is
|
|
# re-pointed to the reference body's paint at the end of the run.
|
|
img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
|
|
img.save()
|
|
return img
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Region mask: R = hood roll, G = body, B = sleeves + pocket
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _classify_hoodie(center, thr, lm):
|
|
x, y, z = center.x, center.y, center.z
|
|
if abs(x) >= thr["sleeve_x_abs"]:
|
|
return (0.0, 0.0, 1.0, 0.0) # sleeves -> B
|
|
hd = math.hypot(x, y - lm["neck_y"])
|
|
is_front = y * base.FRONT_Y_SIGN > 0.0
|
|
drop = R_Z_DROP_FRONT if is_front else R_Z_DROP_BACK
|
|
z_min = thr["collar_z_min"] - drop * lm["neck_len"]
|
|
if z >= z_min and hd <= lm["roll_reach"] + lm["roll_out"] + 0.01:
|
|
return (1.0, 0.0, 0.0, 0.0) # hood roll -> R
|
|
return (0.0, 1.0, 0.0, 0.0) # body -> G
|
|
|
|
|
|
def bake_hoodie_mask(shell, out_path, thr, lm, pocket_px):
|
|
"""Per-face region rasterization (like base.bake_region_mask) with the
|
|
hoodie classifier, then the pocket texels overlaid into B."""
|
|
W = H = base.MASK_SIZE
|
|
buf = np.zeros((H, W, 4), dtype=np.float32)
|
|
buf[:, :, 1] = 1.0 # body-green background (bleed safety)
|
|
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
bm.faces.ensure_lookup_table()
|
|
uv_layer = bm.loops.layers.uv.active
|
|
if uv_layer is None:
|
|
raise RuntimeError("no active UV layer for region mask bake")
|
|
counts = {"roll": 0, "body": 0, "sleeve": 0}
|
|
for face in bm.faces:
|
|
color = _classify_hoodie(face.calc_center_median(), thr, lm)
|
|
if color[0] > 0.5:
|
|
counts["roll"] += 1
|
|
elif color[2] > 0.5:
|
|
counts["sleeve"] += 1
|
|
else:
|
|
counts["body"] += 1
|
|
for a, b, c in base._tris_from_face(face, uv_layer):
|
|
base._raster_tri(buf, a, b, c, color, W, H)
|
|
bm.free()
|
|
total = max(sum(counts.values()), 1)
|
|
log("region faces: " + " ".join(
|
|
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
|
|
|
|
# Pocket joins the sleeve tint region (B), per the spec.
|
|
if pocket_px is not None and pocket_px.shape == (H, W):
|
|
buf[pocket_px] = (0.0, 0.0, 1.0, 0.0)
|
|
|
|
img = bpy.data.images.new(f"{GARMENT_ID}_mask", W, H, alpha=True)
|
|
img.pixels.foreach_set(buf.reshape(-1))
|
|
img.update()
|
|
img.filepath_raw = out_path
|
|
img.file_format = 'PNG'
|
|
img.save()
|
|
log(f"baked region mask -> {out_path}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-body authoring
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_hoodie(body_dir, out_dir, body, offset):
|
|
base.clear_scene()
|
|
shell, armature = base.build_covered_mesh(body_dir)
|
|
thr = base.derive_thresholds(armature)
|
|
lm = derive_landmarks(armature, thr)
|
|
|
|
wrist_cut(shell, lm)
|
|
base.offset_outward(shell, offset)
|
|
hood_roll(shell, thr, lm)
|
|
base.solidify(shell, THICKNESS_M)
|
|
|
|
maps = bake_paint_maps(shell, base.ALBEDO_SIZE, base.ALBEDO_SIZE)
|
|
p = derive_paint_params(shell, thr, lm)
|
|
pocket_px = pocket_mask(maps, p)
|
|
|
|
albedo_img = make_painted_albedo(maps, pocket_px, p, out_dir, body)
|
|
base.assign_fabric_material(shell, albedo_img)
|
|
base.author_logo_uv(shell, thr)
|
|
|
|
# Mask texels are MASK_SIZE; paint maps are ALBEDO_SIZE. Sizes match (512)
|
|
# today; rebake the pocket mask if they ever diverge.
|
|
mask_pocket = pocket_px if base.MASK_SIZE == base.ALBEDO_SIZE else None
|
|
bake_hoodie_mask(shell, os.path.join(out_dir, f"{body}_mask.png"),
|
|
thr, lm, mask_pocket)
|
|
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]")
|
|
sys.exit(1)
|
|
bodies_root = argv[0]
|
|
out_dir = argv[1]
|
|
offset = OFFSET_M
|
|
if "--offset" in argv:
|
|
offset = float(argv[argv.index("--offset") + 1])
|
|
bodies = base.BODY_TYPES
|
|
if "--bodies" in argv:
|
|
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
|
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
base.COVERED_SEGMENTS = SEGMENTS
|
|
|
|
log(f"per-body hoodie: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
|
|
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_hoodie(body_dir, out_dir, body, offset)
|
|
results.append((body, "ok"))
|
|
except Exception as exc:
|
|
log(f"ERROR {body}: {exc}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
results.append((body, f"error: {exc}"))
|
|
|
|
# Runtime fallbacks mirror the reference body (average_m).
|
|
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
|
|
if os.path.isfile(ref_mask):
|
|
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
|
log("copied reference_mask.png fallback")
|
|
ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
|
|
if os.path.isfile(ref_albedo):
|
|
shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
|
|
log("copied base_albedo.png fallback")
|
|
|
|
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()
|