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>
598 lines
24 KiB
Python
598 lines
24 KiB
Python
"""
|
|
blender_author_sweater.py (T-1089 wave 2 — sweater_modern, per-body offset shell)
|
|
|
|
Crew-neck pullover authored per body via the offset-shell route. Imports
|
|
blender_author_offset_shell.py as the shared library (segment join, bone-ratio
|
|
thresholds, offset, solidify, logo UV2, GLB export) and
|
|
blender_author_denim_pants.py for the wave-1-proven boundary practices
|
|
(coincident segment-seam WELD, seg_hips crotch probe). What the sweater adds,
|
|
as reusable parameters:
|
|
|
|
* coverage: torso + torso_upper + FULL arms (cuffs at the wrist) + the UPPER
|
|
seg_hips band. Including seg_hips is the hem answer for tops: the natural
|
|
seg_torso bottom boundary is a 9-14 cm jagged tooth ring (probe evidence,
|
|
all bodies) and flattening it up/down either opens skin holes (hips skin
|
|
fails to reach the teeth tops in 2/24 azimuth bins) or invents cloth off
|
|
the body surface. Instead the shell continues into real hip geometry and
|
|
is hem-CUT at a clean plane derived from bone landmarks (spine_01 ->
|
|
crotch fraction), then the cut ring is flattened onto that plane
|
|
(denim-pants ankle practice; pull distance ~ one face, not full teeth).
|
|
Hips stay UNHIDDEN in coverage.json — skin continues under the hem, so no
|
|
hole is geometrically possible (uniform_utility waist-join precedent).
|
|
* crew-neck CUT + rim CIRCULARIZATION — the seg_torso_upper neckline is a
|
|
jagged tooth ring whose VALLEY sits at neck_z + ~0.23*neck_len on every
|
|
body (probe evidence, 6 bodies) — exactly crew height — while its teeth
|
|
reach ~72% up the neck. Raising the rim to the teeth max (the first
|
|
draft) reads as a chin-high mock-neck, not the crew the spec asks. So:
|
|
delete the neck-tube verts ABOVE the valley within the ring's own
|
|
measured hd_max * CUT_R_MUL (per-body self-calibrating radius — the
|
|
neck-trap flare shell probes strictly outside it), then flatten the new
|
|
boundary onto the valley plane: verts within r_med * CREW_RIM_R_MUL of
|
|
the neck axis are circularized to the ring median radius (clean crew
|
|
rim); flare verts beyond only drop to the plane (no radial pinch, which
|
|
would fold cloth on wide-trap bodies). Skin-hole-safe: seg_neck skin
|
|
reaches down exactly to the valley (probe), and runtime never hides
|
|
segments anyway.
|
|
* wrist rim flattening onto the cut plane (denim ankle practice on the arm
|
|
axis) — the vert-threshold cut leaves 1-3 cm teeth; the painted ribbed
|
|
cuff needs a clean edge.
|
|
* cuff PINCH — per-vert offset scale tapering to CUFF_PINCH at the wrist so
|
|
the ribbed cuffs read cinched (knit hugs the wrist).
|
|
* painted knit identity, driven per texel from final 3D positions
|
|
(buttondown per-pixel bake machinery): ribbed collar / cuffs / hem —
|
|
azimuthal rib stripes around the neck axis, arm axis, and torso axis —
|
|
plus border stitch lines. All LUMINANCE detail, so the luma-preserving
|
|
toon_garment recolor keeps it under any tint. Warm muted default tints
|
|
live in the manifest entry, not the albedo.
|
|
* region mask (spec): collar=R (tint_0), body=G (tint_1), cuffs+hem=B
|
|
(tint_2). A unused. Logo-capable OFF per spec, but the UV2 chest channel
|
|
is still authored (costs nothing; buttondown precedent).
|
|
|
|
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
|
|
|
|
tooling/blender --background --python \
|
|
tooling/garment-fit/blender_author_sweater.py -- \
|
|
client/assets/characters/bodies \
|
|
client/assets/characters/clothing/sweater_modern \
|
|
[--bodies average_m,child,...] [--offset 0.014] [--hem-drop 0.30] \
|
|
[--rib-period 0.011] [--base-rgb 0.62,0.585,0.545]
|
|
|
|
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 math
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import bmesh
|
|
import bpy
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import blender_author_offset_shell as base # noqa: E402
|
|
import blender_author_denim_pants as denim # noqa: E402 (weld + hips probe)
|
|
import blender_author_buttondown as bd # noqa: E402 (per-pixel bake helpers)
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Parameters (the reusable seam — override for cardigan/turtleneck variants)
|
|
# --------------------------------------------------------------------------
|
|
|
|
GARMENT_ID = "sweater_modern"
|
|
|
|
SEGMENTS = [
|
|
"seg_torso", "seg_torso_upper",
|
|
"seg_arm_upper_l", "seg_arm_upper_r",
|
|
"seg_arm_lower_l", "seg_arm_lower_r",
|
|
"seg_hips",
|
|
]
|
|
|
|
OFFSET_M = 0.014 # knit-weight standoff (tee 12 / hoodie 16)
|
|
THICKNESS_M = 0.005 # knit cloth thickness (tee 4 / hoodie 6)
|
|
WRIST_FRAC = 0.94 # fraction of lowerarm kept — cuffs AT the wrist
|
|
CUFF_PINCH = 0.75 # offset scale at the wrist rim (ribbing cinches)
|
|
CUFF_LEN_FRAC = 0.18 # ribbed cuff length, fraction of lowerarm length
|
|
HEM_DROP_FRAC = 0.30 # hem below the spine_01 waistline, fraction of
|
|
# (waistline - crotch) — hip-length pullover
|
|
HEM_BAND_FRAC = 0.085 # ribbed hem band, fraction of (collar_z - hem_z)
|
|
COLLAR_REACH_FRAC = 1.45 # collar region radial reach, x collar_x_abs
|
|
CUT_R_MUL = 1.05 # crew cut radius, x the neck ring's measured hd_max
|
|
CREW_RIM_R_MUL = 1.6 # circularize rim verts within this x ring r_med
|
|
COLLAR_BAND_FRAC = 0.32 # ribbed collar band height below the crew rim,
|
|
# x neck_len (~2.5 cm on average_m) — anchored to
|
|
# the MEASURED rim, not collar_z_min, so the band
|
|
# reads as a crew rib, not a chest yoke
|
|
|
|
# Knit paint (luminance detail; absolute mm scaled by the body's shoulder
|
|
# ratio so the rib gauge reads identical from child to heavy_m).
|
|
RIB_PERIOD_M = 0.011 # one rib pair (dark+light) around the band
|
|
RIB_DARK = 0.85 # dark rib stripe multiplier
|
|
BAND_MUL = 0.94 # overall band shade vs body knit
|
|
LINE_MUL = 0.70 # border stitch line multiplier
|
|
LINE_HALF_M = 0.0022 # border stitch line half-width
|
|
|
|
# Warm muted base fabric (default tints in the manifest carry the colour;
|
|
# the albedo carries luma detail + the untinted fallback tone).
|
|
FABRIC_RGB = (0.620, 0.585, 0.545)
|
|
FABRIC_NOISE = 0.030
|
|
ALBEDO_SEED = 2093
|
|
|
|
MASK_SIZE = 512
|
|
ALBEDO_SIZE = 1024
|
|
|
|
|
|
def log(msg):
|
|
print(f"[sweater] {msg}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Landmarks (all bone-proportional; same philosophy as base.derive_thresholds)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def derive_landmarks(armature, thr, crotch_z):
|
|
bones = armature.data.bones
|
|
neck = bones.get("neck_01")
|
|
la_l = bones.get("lowerarm_l")
|
|
la_r = bones.get("lowerarm_r")
|
|
spine01 = bones.get("spine_01")
|
|
ua_l = bones.get("upperarm_l")
|
|
ua_r = bones.get("upperarm_r")
|
|
if not all([neck, la_l, la_r, spine01, ua_l, ua_r]):
|
|
raise RuntimeError("landmark bones missing (neck_01/lowerarm/spine_01)")
|
|
|
|
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
|
s = shoulder_x / base._REF_SHOULDER_X
|
|
|
|
def along(b, frac):
|
|
return b.head_local.x + frac * (b.tail_local.x - b.head_local.x)
|
|
|
|
cut_l = along(la_l, WRIST_FRAC) # left arm +x: delete x > cut_l
|
|
cut_r = along(la_r, WRIST_FRAC) # right arm -x: delete x < cut_r
|
|
lowerarm_len = abs(la_l.tail_local.x - la_l.head_local.x)
|
|
wrist_x_abs = (abs(cut_l) + abs(cut_r)) / 2.0
|
|
|
|
waist_z = spine01.head_local.z
|
|
hem_z = waist_z - HEM_DROP_FRAC * (waist_z - crotch_z)
|
|
|
|
# Arm rib axis (y, z) — midpoint of the lowerarm bone, shared by both
|
|
# sides (the rig mirrors in x only).
|
|
az_y = (la_l.head_local.y + la_l.tail_local.y) / 2.0
|
|
az_z = (la_l.head_local.z + la_l.tail_local.z) / 2.0
|
|
|
|
lm = {
|
|
"style": s,
|
|
"neck_y": neck.head_local.y,
|
|
"neck_len": neck.tail_local.z - neck.head_local.z,
|
|
"cut_l": cut_l,
|
|
"cut_r": cut_r,
|
|
"wrist_x_abs": wrist_x_abs,
|
|
"cuff_x0": wrist_x_abs - CUFF_LEN_FRAC * lowerarm_len,
|
|
"arm_axis_yz": (az_y, az_z),
|
|
"waist_z": waist_z,
|
|
"crotch_z": crotch_z,
|
|
"hem_z": hem_z,
|
|
"collar_reach": COLLAR_REACH_FRAC * thr["collar_x_abs"],
|
|
"rib_period": RIB_PERIOD_M * s,
|
|
"line_half": LINE_HALF_M * s,
|
|
}
|
|
lm["hem_top"] = hem_z + HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
|
|
log(f"landmarks: style {s:.3f} wrist cuts ({cut_l:.3f},{cut_r:.3f}) "
|
|
f"cuff_x0 {lm['cuff_x0']:.3f} hem {hem_z:.3f} (waist {waist_z:.3f}, "
|
|
f"crotch {crotch_z:.3f}) hem_top {lm['hem_top']:.3f} "
|
|
f"collar reach {lm['collar_reach']:.3f}")
|
|
return lm
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Geometry: wrist + hem cuts, rim flattening, crew-collar circularization
|
|
# --------------------------------------------------------------------------
|
|
|
|
def wrist_cut(shell, lm):
|
|
"""Trim the sleeve tubes at the wrist plane (hoodie/jacket pattern)."""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
doomed = [v for v in bm.verts
|
|
if v.co.x > lm["cut_l"] or v.co.x < lm["cut_r"]]
|
|
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"wrist cut removed {len(doomed)} verts; "
|
|
f"{len(shell.data.vertices)} remain")
|
|
|
|
|
|
def hem_cut(shell, lm):
|
|
"""Trim the hips continuation below the hem plane (real hip geometry, so
|
|
the surviving cloth conforms to the body — no invented coverage)."""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
doomed = [v for v in bm.verts if v.co.z < lm["hem_z"]]
|
|
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"hem cut at z={lm['hem_z']:.3f}: removed {len(doomed)} verts")
|
|
|
|
|
|
def crew_cut(shell, thr, lm):
|
|
"""Cut the neck tube down to crew height (probe-driven, per body).
|
|
|
|
The seg_torso_upper neck boundary is a jagged tooth ring: valley at
|
|
~neck_z + 0.23*neck_len (crew height), teeth up to ~72% of the neck.
|
|
Measure the ring (valley z, median + max horizontal distance to the neck
|
|
axis), then delete every vert ABOVE the valley within hd_max * CUT_R_MUL
|
|
of the axis — the tube and its teeth, and provably not the neck-trap
|
|
flare shell (probe: flare hd sits well outside hd_max on all bodies).
|
|
Stores crew_z / crew_r in lm for the flatten pass.
|
|
"""
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
boundary = set()
|
|
for e in bm.edges:
|
|
if len(e.link_faces) == 1:
|
|
boundary.update(v.index for v in e.verts)
|
|
|
|
mid_l = 0.5 * (thr["sleeve_x_abs"] + lm["cut_l"])
|
|
mid_r = -0.5 * (thr["sleeve_x_abs"] + abs(lm["cut_r"]))
|
|
z_split = 0.5 * (lm["hem_z"] + thr["collar_z_min"])
|
|
ring = []
|
|
for i in boundary:
|
|
v = bm.verts[i]
|
|
if v.co.x > mid_l or v.co.x < mid_r or v.co.z <= z_split:
|
|
continue
|
|
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
|
|
if hd <= lm["collar_reach"] * 1.5:
|
|
ring.append((hd, v.co.z))
|
|
if not ring:
|
|
bm.free()
|
|
raise RuntimeError("no neck boundary ring found for crew cut")
|
|
lm["crew_z"] = min(z for _, z in ring)
|
|
lm["crew_r"] = float(np.median([hd for hd, _ in ring]))
|
|
hd_max = max(hd for hd, _ in ring)
|
|
r_cut = hd_max * CUT_R_MUL
|
|
|
|
doomed = [v for v in bm.verts
|
|
if v.co.z > lm["crew_z"]
|
|
and math.hypot(v.co.x, v.co.y - lm["neck_y"]) <= r_cut]
|
|
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
lm["collar_band_z0"] = lm["crew_z"] - COLLAR_BAND_FRAC * lm["neck_len"]
|
|
log(f"crew cut: ring valley z={lm['crew_z']:.3f} r_med={lm['crew_r']:.3f} "
|
|
f"hd_max={hd_max:.3f} -> removed {len(doomed)} tube verts "
|
|
f"(r_cut {r_cut:.3f}); collar band z0={lm['collar_band_z0']:.3f}")
|
|
|
|
|
|
def flatten_rims_and_collar(shell, thr, lm):
|
|
"""Clean every open rim (adapted from the denim flatten_open_rims
|
|
practice; only boundary verts move, weights/UVs ride along).
|
|
|
|
wrist rings -> the wrist cut planes (x per side)
|
|
hem ring -> the hem plane (z)
|
|
neck ring -> the crew plane (crew_z from crew_cut): verts within
|
|
crew_r * CREW_RIM_R_MUL of the neck axis circularize to
|
|
the ring median radius (clean crew rim); flare verts
|
|
beyond only drop onto the plane — no radial pinch, which
|
|
would fold cloth on wide-trap bodies.
|
|
"""
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
boundary = set()
|
|
for e in bm.edges:
|
|
if len(e.link_faces) == 1:
|
|
boundary.update(v.index for v in e.verts)
|
|
|
|
mid_l = 0.5 * (thr["sleeve_x_abs"] + lm["cut_l"])
|
|
mid_r = -0.5 * (thr["sleeve_x_abs"] + abs(lm["cut_r"]))
|
|
z_split = 0.5 * (lm["hem_z"] + thr["collar_z_min"])
|
|
|
|
wrist_n = hem_n = rim_n = flare_n = 0
|
|
stray = 0
|
|
for i in boundary:
|
|
v = bm.verts[i]
|
|
if v.co.x > mid_l:
|
|
v.co.x = lm["cut_l"]
|
|
wrist_n += 1
|
|
elif v.co.x < mid_r:
|
|
v.co.x = lm["cut_r"]
|
|
wrist_n += 1
|
|
elif v.co.z <= z_split:
|
|
v.co.z = lm["hem_z"]
|
|
hem_n += 1
|
|
else:
|
|
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
|
|
if hd <= lm["crew_r"] * CREW_RIM_R_MUL:
|
|
if hd > 1e-6:
|
|
f = lm["crew_r"] / hd
|
|
v.co.x *= f
|
|
v.co.y = lm["neck_y"] + (v.co.y - lm["neck_y"]) * f
|
|
v.co.z = lm["crew_z"]
|
|
rim_n += 1
|
|
elif hd <= lm["collar_reach"] * 1.5:
|
|
v.co.z = lm["crew_z"]
|
|
flare_n += 1
|
|
else:
|
|
stray += 1
|
|
|
|
if rim_n == 0:
|
|
log("WARNING: no crew rim verts found — collar left ragged")
|
|
log(f"flattened rims: wrist {wrist_n} verts -> cut planes, "
|
|
f"hem {hem_n} -> z={lm['hem_z']:.3f}, crew rim {rim_n} -> circle "
|
|
f"r={lm['crew_r']:.3f} z={lm['crew_z']:.3f}, flare {flare_n} -> "
|
|
f"plane, stray {stray}")
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
|
|
|
|
def offset_with_cuff_pinch(shell, offset, lm):
|
|
"""Outward normal offset with the standoff tapering to CUFF_PINCH over
|
|
the ribbed cuff band (knit cuffs hug the wrist)."""
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.normal_update()
|
|
span = max(lm["wrist_x_abs"] - lm["cuff_x0"], 1e-6)
|
|
pinched = 0
|
|
for v in bm.verts:
|
|
s = 1.0
|
|
ax = abs(v.co.x)
|
|
if ax > lm["cuff_x0"]:
|
|
t = min((ax - lm["cuff_x0"]) / span, 1.0)
|
|
s = 1.0 - (1.0 - CUFF_PINCH) * t
|
|
pinched += 1
|
|
v.co += v.normal * (offset * s)
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
log(f"offset {offset*1000:.0f} mm outward "
|
|
f"({pinched} cuff verts pinched to {CUFF_PINCH:.2f}x)")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-pixel knit paint + region mask (buttondown bake machinery)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _rib_stripes(arc, period):
|
|
"""Bool array: dark rib stripe (half of each rib pair)."""
|
|
return np.mod(arc / period, 1.0) < 0.5
|
|
|
|
|
|
def _classify_px(pos, thr, lm):
|
|
"""Region mask rows: collar=R, body=G, cuffs+hem=B (spec)."""
|
|
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
|
ax = np.abs(x)
|
|
hd = np.hypot(x, y - lm["neck_y"])
|
|
collar = (z >= lm["collar_band_z0"]) & (hd <= lm["collar_reach"])
|
|
band_b = (~collar) & ((ax >= lm["cuff_x0"]) | (z <= lm["hem_top"]))
|
|
rgba = np.zeros((len(x), 4), dtype=np.float32)
|
|
rgba[:, 1] = 1.0 # default: body -> G
|
|
rgba[collar] = (1.0, 0.0, 0.0, 0.0) # crew collar -> R
|
|
rgba[band_b] = (0.0, 0.0, 1.0, 0.0) # cuffs + hem -> B
|
|
return rgba
|
|
|
|
|
|
def _paint_px(pos, rows, thr, lm):
|
|
"""Knit luminance detail: ribbed collar/cuff/hem + border stitch lines."""
|
|
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
|
ax = np.abs(x)
|
|
hd = np.hypot(x, y - lm["neck_y"])
|
|
period = lm["rib_period"]
|
|
line = lm["line_half"]
|
|
|
|
collar = (z >= lm["collar_band_z0"]) & (hd <= lm["collar_reach"])
|
|
cuff = (~collar) & (ax >= lm["cuff_x0"])
|
|
hem = (~collar) & (~cuff) & (z <= lm["hem_top"])
|
|
|
|
mul = np.ones(len(x), dtype=np.float32)
|
|
mul[collar | cuff | hem] = BAND_MUL
|
|
|
|
# Collar ribs: azimuth around the neck axis. atan2's +-pi discontinuity
|
|
# sits where the 2nd arg is negative — (y-cy)*FRONT_Y_SIGN < 0 is the
|
|
# BACK on this rig, so the rib wrap seam hides at centre-back.
|
|
arc = np.arctan2(x, (y - lm["neck_y"]) * base.FRONT_Y_SIGN) * hd
|
|
sel = collar & _rib_stripes(arc, period)
|
|
mul[sel] *= RIB_DARK
|
|
|
|
# Cuff ribs: azimuth around the arm axis (wrap seam under the arm).
|
|
ay, az_ = lm["arm_axis_yz"]
|
|
r_arm = np.hypot(y - ay, z - az_)
|
|
arc = np.arctan2(y - ay, z - az_) * r_arm
|
|
sel = cuff & _rib_stripes(arc, period)
|
|
mul[sel] *= RIB_DARK
|
|
|
|
# Hem ribs: azimuth around the torso axis (wrap seam at centre-back).
|
|
r_t = np.hypot(x, y - lm["hem_cy"])
|
|
arc = np.arctan2(x, (y - lm["hem_cy"]) * base.FRONT_Y_SIGN) * r_t
|
|
sel = hem & _rib_stripes(arc, period)
|
|
mul[sel] *= RIB_DARK
|
|
|
|
# Border stitch lines at each band's inner edge.
|
|
mul[(np.abs(z - lm["collar_band_z0"]) <= line)
|
|
& (hd <= lm["collar_reach"] * 1.1)] = LINE_MUL
|
|
mul[np.abs(ax - lm["cuff_x0"]) <= line] = LINE_MUL
|
|
mul[(np.abs(z - lm["hem_top"]) <= line) & (~cuff)] = LINE_MUL
|
|
|
|
out = rows * mul[:, None]
|
|
np.clip(out, 0.0, 1.0, out)
|
|
return out
|
|
|
|
|
|
def bake_maps(shell, thr, lm, mask_path):
|
|
"""One pass over UV0 triangles: bake <body>_mask.png + painted albedo."""
|
|
tris = bd._gather_tris(shell)
|
|
|
|
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
|
|
mbuf[:, :, 1] = 1.0 # body-green background (bilinear-bleed safe)
|
|
|
|
rng = np.random.default_rng(ALBEDO_SEED)
|
|
fabric = np.array(FABRIC_RGB, dtype=np.float32)
|
|
noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
|
|
- 0.5) * 2.0 * FABRIC_NOISE
|
|
abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
|
|
|
# Hem torso-axis centre from the final mesh (paint anchor).
|
|
hem_ys = [v.co.y for v in shell.data.vertices if v.co.z <= lm["hem_top"]]
|
|
lm["hem_cy"] = float(np.mean(hem_ys)) if hem_ys else 0.0
|
|
|
|
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
|
|
cover = bd._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
|
|
if cover is not None:
|
|
pos = bd._interp_pos(cover, co_a, co_b, co_c)
|
|
mbuf[cover[0], cover[1], :] = _classify_px(pos, thr, lm)
|
|
cover = bd._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
|
|
if cover is not None:
|
|
pos = bd._interp_pos(cover, co_a, co_b, co_c)
|
|
abuf[cover[0], cover[1], :] = _paint_px(
|
|
pos, abuf[cover[0], cover[1], :], thr, lm)
|
|
|
|
tot = MASK_SIZE * MASK_SIZE
|
|
log("mask texels: collar={:.1f}% body={:.1f}% cuffs+hem={:.1f}%".format(
|
|
100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
|
|
100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
|
|
100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
|
|
|
|
mask_img = bpy.data.images.new(f"{GARMENT_ID}_mask", MASK_SIZE, MASK_SIZE,
|
|
alpha=True)
|
|
mask_img.pixels.foreach_set(mbuf.reshape(-1))
|
|
mask_img.update()
|
|
mask_img.filepath_raw = mask_path
|
|
mask_img.file_format = 'PNG'
|
|
mask_img.save()
|
|
log(f"baked region mask -> {mask_path}")
|
|
|
|
argba = np.concatenate(
|
|
[abuf, np.ones((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)],
|
|
axis=2)
|
|
albedo_img = bpy.data.images.new(f"{GARMENT_ID}_albedo", ALBEDO_SIZE,
|
|
ALBEDO_SIZE, alpha=False)
|
|
albedo_img.pixels.foreach_set(argba.reshape(-1))
|
|
albedo_img.update()
|
|
return albedo_img
|
|
|
|
|
|
def save_albedo(albedo_img, out_dir, body):
|
|
"""Sidecar + embed naming (hoodie convention): the glTF exporter names the
|
|
embedded image after the file basename and Godot extracts it as
|
|
<glb>_<imagename>.png, so pointing the image at base_albedo.png makes the
|
|
extraction land exactly on <body>_base_albedo.png."""
|
|
sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
|
|
albedo_img.filepath_raw = sidecar
|
|
albedo_img.file_format = 'PNG'
|
|
albedo_img.save()
|
|
log(f"painted albedo -> {sidecar}")
|
|
albedo_img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
|
|
albedo_img.save()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-body authoring
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_sweater(body_dir, out_dir, body, offset):
|
|
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
|
|
|
|
base.clear_scene()
|
|
base.COVERED_SEGMENTS = SEGMENTS
|
|
shell, armature = base.build_covered_mesh(body_dir)
|
|
thr = base.derive_thresholds(armature)
|
|
lm = derive_landmarks(armature, thr, crotch_z)
|
|
|
|
denim.weld_boundaries(shell)
|
|
wrist_cut(shell, lm)
|
|
hem_cut(shell, lm)
|
|
crew_cut(shell, thr, lm)
|
|
flatten_rims_and_collar(shell, thr, lm)
|
|
offset_with_cuff_pinch(shell, offset, lm)
|
|
base.solidify(shell, THICKNESS_M)
|
|
|
|
base.author_logo_uv(shell, thr) # UV2 before bakes (mask/albedo use UV0)
|
|
albedo_img = bake_maps(shell, thr, lm,
|
|
os.path.join(out_dir, f"{body}_mask.png"))
|
|
save_albedo(albedo_img, 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():
|
|
global OFFSET_M, HEM_DROP_FRAC, RIB_PERIOD_M, FABRIC_RGB
|
|
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] [--hem-drop F] [--rib-period M] "
|
|
"[--base-rgb r,g,b]")
|
|
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])
|
|
if "--hem-drop" in argv:
|
|
HEM_DROP_FRAC = float(argv[argv.index("--hem-drop") + 1])
|
|
if "--rib-period" in argv:
|
|
RIB_PERIOD_M = float(argv[argv.index("--rib-period") + 1])
|
|
if "--base-rgb" in argv:
|
|
FABRIC_RGB = tuple(
|
|
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
|
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"per-body sweater: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
|
|
f"hem-drop {HEM_DROP_FRAC}")
|
|
|
|
avg_albedo_stash = os.path.join(out_dir, "_tmp_avg_base_albedo.png")
|
|
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_sweater(body_dir, out_dir, body, offset)
|
|
if body == base.REFERENCE_BODY:
|
|
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
|
avg_albedo_stash)
|
|
results.append((body, "ok"))
|
|
except Exception as exc: # noqa: BLE001 — per-body isolation
|
|
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")
|
|
if os.path.isfile(avg_albedo_stash):
|
|
shutil.move(avg_albedo_stash, os.path.join(out_dir, "base_albedo.png"))
|
|
log(f"base_albedo.png = {base.REFERENCE_BODY}'s painted albedo")
|
|
|
|
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()
|