Files
settled-reach/tooling/scripts/blender/blender_author_denim_pants.py
jpmschweitzerandClaude Opus 5 201dabd19b refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
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>
2026-09-02 20:55:52 +02:00

660 lines
26 KiB
Python

"""
blender_author_denim_pants.py (T-1089, jeans_modern + denim pants family)
Authors full-length DENIM pants as per-body offset shells, reusing
blender_author_offset_shell.py as a library (scene build, join, offset,
solidify, GLB export). Sibling companions already cover the plain lower-body
family (blender_author_offset_shell_legs.py — shorts, 2-region;
blender_author_lower_shell.py — formal, crease lines); this one adds what
denim needs and they don't provide, as reusable parameters:
* TEXEL-level feature painting: every UV0 triangle is rasterized with
barycentric-interpolated 3D positions, and an analytic denim feature
field is evaluated per texel. ONE field evaluation drives BOTH outputs,
so the painted albedo and the region mask always agree:
- albedo: painted seam thread lines (outseam/inseam azimuth around the
per-z leg axis, centre-front fly, back yoke V), front pocket
arcs, back pocket outlines, belt loops, waist button,
waist/cuff border stitching — flat toon-friendly, identity
carried by the texture (style pin: modern only).
- mask: waistband -> R, legs -> G, seams + cuff band -> B
(spec: waistband=R, legs=G, seams/cuffs=B). Seam LINES are
in the B channel, not just the cuff band, so contrast-thread
recoloring works (toon_garment.gdshader channel-blends).
* boundary WELD before offsetting — the waist join (hips<->leg_upper) and
knee join (leg_upper<->leg_lower) carry duplicated boundary-ring verts
per segment; offsetting un-welded rings along diverging normals opens
cracks, so coincident verts are merged first (weights identical by
origin, so skinning is unaffected).
* --hem-frac: fraction of the hip->ankle span covered, measured up from the
ankle (1.0 = full length to the ankle = jeans; lower values give cropped
variants; cut rims are capped by Solidify(use_rim) like the base sleeves).
* a parked logo_uv TEXCOORD_1 layer (all UVs at (2,2)): pants are not
logo-capable, but toon_garment.gdshader samples UV2 unconditionally, so
every multi_region garment ships a well-defined logo channel.
* open-rim FLATTENING: the segment splitter cuts along weight thresholds,
so the waist and ankle boundary rings are jagged "teeth" (~5-6 cm deep on
average_m); boundary verts are pulled onto clean planes (waist ring down
to its own valley, ankle rings onto the ankle-joint plane) pre-offset.
* --waist-flare: extra feathered radial stand-off at the waistband rim —
deep-crouch waist-fold clip mitigation (QA evidence, peasant + jeans).
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r. Natural
boundaries give the waist opening (seg_hips top rim) and the ankle hems
(seg_leg_lower bottom) for free.
All cut/mask/paint parameters derive PER BODY from that body's own bone
landmarks (thigh_l/calf_l line) and measured mesh extents (waist rim, ankle
rim, crotch = seg_hips lowest ring), scaled by the body's garment span and
hip half-width — the same proportional-ratio philosophy as
base.derive_thresholds, so the denim details stay consistent across all 11
bodies. Per-body mode only (offset shells author per body, Q-060).
Usage (jeans_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_denim_pants.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/jeans_modern \
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 1.0] \
[--waist-flare 0.007] [--base-rgb 0.24,0.32,0.45] [--plain]
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 (clothing pre-fitted per body type), D-251 (in-house
wardrobe), Q-060 (per-body offset shells).
"""
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)
log = base.log
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
# --------------------------------------------------------------------------
# Parameters
# --------------------------------------------------------------------------
COVERED_SEGMENTS = [
"seg_hips",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
HEM_FRAC = 1.0 # 1.0 = full leg to the ankle (jeans)
WELD_DIST = 5e-4 # boundary-ring weld tolerance (0.5 mm)
TEX_SIZE = 1024 # albedo + mask resolution (painted seams need >512)
WAIST_FLARE_M = 0.007 # extra radial stand-off at the waistband rim
# (deep-crouch waist-fold mitigation, feathered)
# Vertical proportions — fractions of the garment span (waist_z - hem_z).
BAND_FRAC = 0.055 # waistband height (R region)
CUFF_FRAC = 0.032 # cuff band height (B region)
SEAM_W_FRAC = 0.0095 # painted seam line width
YOKE_DROP_FRAC = 0.055 # back yoke centre depth below the waistband
YOKE_RISE_FRAC = 0.030 # yoke V rise toward the sides
BPOCKET_TOP_FRAC = 0.075 # back pocket top below the waistband
BPOCKET_HH_FRAC = 0.048 # back pocket half-height
FPOCKET_RZ_FRAC = 0.14 # front pocket arc vertical radius
# Horizontal proportions — fractions of the hip half-width (|thigh head x|).
BPOCKET_CX_FRAC = 0.80 # back pocket centre
BPOCKET_HW_FRAC = 0.52 # back pocket half-width
FPOCKET_CX_FRAC = 1.30 # front pocket arc centre (near the outseam corner)
FPOCKET_RX_FRAC = 0.75 # front pocket arc horizontal radius
LOOP_X_FRACS = (0.55, 1.30) # belt-loop |x| positions (front + back pairs)
LOOP_W_M = 0.016 # belt loop width (m, scaled by hip width)
# Denim style (sRGB floats; saved as-is — matches the proven base pipeline).
DENIM_RGB = (0.240, 0.320, 0.450) # indigo denim
THREAD_RGB = (0.800, 0.620, 0.340) # contrast stitching thread
BUTTON_RGB = (0.850, 0.720, 0.480) # waist button metal
ALBEDO_NOISE = 0.020 # +/- woven jitter
CUFF_SHADE = 0.90 # cuff band albedo darkening
LOOP_SHADE = 0.80 # belt-loop albedo darkening
PLAIN = False # --plain: skip thread/pocket/button paint
NOISE_SEED = 2089
# Reference proportions (average_m) the fractions were calibrated against.
_REF_SPAN = 1.007 # waist rim z (1.093) - ankle z (0.086)
_REF_HIP_X = 0.0906 # |thigh_l head x|
# --------------------------------------------------------------------------
# Per-body landmarks
# --------------------------------------------------------------------------
class LegLandmarks:
"""Cut/mask/paint parameters derived from one body's bones + mesh."""
def __init__(self, armature, waist_z, ankle_z, crotch_z):
bones = armature.data.bones
thigh = bones.get("thigh_l")
calf = bones.get("calf_l")
if thigh is None or calf is None:
raise RuntimeError("thigh_l/calf_l missing — not the 65-bone rig?")
self.waist_z = waist_z
self.ankle_z = ankle_z
self.crotch_z = crotch_z
self.hip_x = abs(thigh.head_local.x)
# Leg axis control points (z-increasing) for azimuth seam placement.
# x values are the +x (left) leg; the right leg mirrors via sign.
self.leg_z_pts = np.array(
[calf.tail_local.z, calf.head_local.z, thigh.head_local.z])
self.leg_x_pts = np.array(
[abs(calf.tail_local.x), abs(calf.head_local.x),
abs(thigh.head_local.x)])
self.leg_y_pts = np.array(
[calf.tail_local.y, calf.head_local.y, thigh.head_local.y])
self.hem_z = ankle_z # finalized after the hem cut
def finalize(self, hem_z):
self.hem_z = hem_z
self.span = self.waist_z - self.hem_z
self.sh = self.hip_x / _REF_HIP_X
self.band_h = BAND_FRAC * self.span
self.cuff_h = CUFF_FRAC * self.span
self.seam_w = SEAM_W_FRAC * self.span
self.band_z = self.waist_z - self.band_h
self.cuff_top = self.hem_z + self.cuff_h
log(f"landmarks: waist={self.waist_z:.3f} hem={self.hem_z:.3f} "
f"crotch={self.crotch_z:.3f} hip_x={self.hip_x:.3f} "
f"band_z={self.band_z:.3f} cuff_top={self.cuff_top:.3f} "
f"seam_w={self.seam_w * 1000:.1f}mm")
def probe_hips_bounds(body_dir):
"""Import seg_hips alone to measure the crotch (its lowest ring) exactly."""
base.clear_scene()
objs = base.import_glb(os.path.join(body_dir, "seg_hips.glb"))
zs = []
for o in objs:
if base.is_body_mesh(o):
zs.extend(v.co.z for v in o.data.vertices)
if not zs:
raise RuntimeError("seg_hips.glb yielded no skinned mesh")
return min(zs), max(zs)
# --------------------------------------------------------------------------
# Geometry: weld + hem cut
# --------------------------------------------------------------------------
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 flatten_open_rims(shell, ankle_plane):
"""Pull the open boundary rings onto clean planes (pre-offset).
The body segment splitter cuts along weight thresholds, not edge loops, so
the seg_hips top edge and the seg_leg_lower ankle edges are jagged rings
of "teeth" (~5-6 cm on average_m). On the skin this hides under the
neighbouring segment; on a garment shell it becomes a ragged silhouette.
The top ring's verts are pulled DOWN to the ring's own valley (deepest
notch) — a clean straight waist edge without inventing coverage. The
bottom rings' verts are moved onto `ankle_plane` (the ankle joint for
full-length pants, or the hem-cut plane for cropped variants) — a clean
straight hem AT the ankle. Only boundary verts move; interior verts are
untouched, and weights/UVs ride along, so skinning and painting are
unaffected. Returns (waist_plane, ankle_plane).
"""
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)
zs = [bm.verts[i].co.z for i in boundary]
z_mid = (min(zs) + max(zs)) / 2.0
top = [i for i in boundary if bm.verts[i].co.z > z_mid]
bottom = [i for i in boundary if bm.verts[i].co.z <= z_mid]
waist_plane = min(bm.verts[i].co.z for i in top)
teeth_top = max(bm.verts[i].co.z for i in top) - waist_plane
teeth_bot_lo = min(bm.verts[i].co.z for i in bottom)
teeth_bot_hi = max(bm.verts[i].co.z for i in bottom)
for i in top:
bm.verts[i].co.z = waist_plane
for i in bottom:
bm.verts[i].co.z = ankle_plane
bm.to_mesh(me)
bm.free()
me.update()
log(f"flattened open rims: waist ring ({len(top)} verts, teeth "
f"{teeth_top:.3f} m) -> {waist_plane:.3f}; ankle rings "
f"({len(bottom)} verts, z {teeth_bot_lo:.3f}..{teeth_bot_hi:.3f}) "
f"-> {ankle_plane:.3f}")
return waist_plane, ankle_plane
def waist_flare(shell, waist_plane, band_h, flare):
"""Extra RADIAL stand-off at the waistband rim, feathered over 2x the band
height (pre-solidify). QA evidence: in deep crouch the lower-back skin
folds over the waist rim (the peasant set's known 'deep-crouch waist gap',
worst on small bodies). Flaring the band outward gives the fold room and
reads as a natural jeans waistband stand-off."""
if flare <= 0.0:
return
z0 = waist_plane - 2.0 * band_h
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.normal_update()
n = 0
for v in bm.verts:
if v.co.z > z0:
t = min((v.co.z - z0) / (2.0 * band_h), 1.0)
nx, ny = v.normal.x, v.normal.y
mag = (nx * nx + ny * ny) ** 0.5
if mag > 1e-6:
v.co.x += flare * t * nx / mag
v.co.y += flare * t * ny / mag
n += 1
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"waist flare: {n} verts, +{flare * 1000:.1f} mm radial at rim "
f"(feathered from z={z0:.3f})")
def clamp_waist_residue(shell, waist_plane):
"""Post-solidify safety clamp: any interior tooth verts still above the
waist plane (multi-triangle teeth) get squashed onto it."""
me = shell.data
n = 0
for v in me.vertices:
if v.co.z > waist_plane:
v.co.z = waist_plane
n += 1
me.update()
if n:
log(f"clamped {n} residual waist verts -> {waist_plane:.3f}")
def hem_cut(shell, lm, hem_frac):
"""Trim the legs below the hem plane. 1.0 keeps the full ankle length."""
if hem_frac >= 0.999:
return lm.ankle_z
hip_z = float(lm.leg_z_pts[-1])
hem_z = lm.ankle_z + (1.0 - hem_frac) * (hip_z - lm.ankle_z)
bm = bmesh.new()
bm.from_mesh(shell.data)
doomed = [v for v in bm.verts if v.co.z < 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={hem_z:.3f}: removed {len(doomed)} verts")
return hem_z
# --------------------------------------------------------------------------
# Denim feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _seam_field(px, py, pz, lm):
"""Evaluate denim features at texel 3D positions (numpy arrays).
Returns bool arrays (seams, thread, in_cuff, in_band, front):
`seams` feeds the mask B channel; `thread` is every painted stitch line.
"""
w2 = lm.seam_w * 0.5
front = py * FRONT_Y_SIGN > 0.004
backside = py * FRONT_Y_SIGN < -0.004
in_band = pz >= lm.band_z
in_cuff = pz <= lm.cuff_top
mid = (~in_band) & (~in_cuff)
# Per-z leg axis, mirrored by x sign.
side = np.where(px >= 0.0, 1.0, -1.0)
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
dx = px - cx
dy = py - cy
r = np.hypot(dx, dy) + 1e-9
# Outseam: azimuth toward the outer (+/-x) direction; constant metric
# width via arc distance. Inseam: inner direction, below the crotch only.
arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
outseam = mid & (arc_out < w2)
arc_in = np.arccos(np.clip(-dx * side / r, -1.0, 1.0)) * r
inseam = mid & (arc_in < w2) & (pz < lm.crotch_z - 0.01 * lm.span / _REF_SPAN)
# Centre-front fly stitch (slightly off-centre, classic J-front).
fly = front & mid & (np.abs(px - 0.012 * lm.sh) < w2) \
& (pz > lm.crotch_z + 0.015 * lm.span / _REF_SPAN)
# Back yoke: shallow V, higher toward the sides.
yoke_z = (lm.band_z - YOKE_DROP_FRAC * lm.span
+ YOKE_RISE_FRAC * lm.span
* np.minimum(np.abs(px) / (1.5 * lm.hip_x), 1.0))
yoke = backside & mid & (np.abs(pz - yoke_z) < w2) \
& (np.abs(px) < 1.6 * lm.hip_x)
seams = outseam | inseam | fly | yoke
# Stitch-only lines (albedo, not mask): waist + cuff border stitching.
wstitch = np.abs(pz - lm.band_z) < w2
cstitch = np.abs(pz - lm.cuff_top) < w2
# Back pocket outlines (rectangle rings on the seat).
bhw = BPOCKET_HW_FRAC * lm.hip_x
bhh = BPOCKET_HH_FRAC * lm.span
bcz = lm.band_z - BPOCKET_TOP_FRAC * lm.span - bhh
adx = np.abs(np.abs(px) - BPOCKET_CX_FRAC * lm.hip_x)
adz = np.abs(pz - bcz)
bpocket = backside & (adx < bhw + w2) & (adz < bhh + w2) \
& ~((adx < bhw - w2) & (adz < bhh - w2))
# Front pocket arcs (quarter-ellipse from waistband toward the outseam).
fcx = FPOCKET_CX_FRAC * lm.hip_x
frx = FPOCKET_RX_FRAC * lm.hip_x
frz = FPOCKET_RZ_FRAC * lm.span
ex = (np.abs(px) - fcx) / frx
ez = (pz - lm.band_z) / frz
fdist = (np.sqrt(ex * ex + ez * ez) - 1.0) * (0.5 * (frx + frz))
fpocket = front & (np.abs(fdist) < w2) & (pz <= lm.band_z) \
& (np.abs(px) <= fcx)
thread = seams | wstitch | cstitch | bpocket | fpocket
return seams, thread, in_cuff, in_band, front
def _paint_texels(px, py, pz, noise, lm):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
n = px.shape[0]
seams, thread, in_cuff, in_band, front = _seam_field(px, py, pz, lm)
# --- albedo ------------------------------------------------------------
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = DENIM_RGB[c] + noise
alb[:, 3] = 1.0
alb[in_cuff, :3] *= CUFF_SHADE
if not PLAIN:
# Belt loops: darkened denim bands on the waistband.
loop_w = LOOP_W_M * lm.sh
loops = np.zeros(n, dtype=bool)
for fx in LOOP_X_FRACS:
loops |= np.abs(np.abs(px) - fx * lm.hip_x) < loop_w * 0.5
loops |= (~front) & (np.abs(px) < loop_w * 0.5) # centre-back loop
loops &= in_band
alb[loops, :3] *= LOOP_SHADE
alb[thread, 0] = THREAD_RGB[0]
alb[thread, 1] = THREAD_RGB[1]
alb[thread, 2] = THREAD_RGB[2]
# Waist button (front, mid-band).
btn = front & (np.hypot(px, pz - (lm.band_z + 0.5 * lm.band_h))
< 0.009 * lm.sh)
alb[btn, 0] = BUTTON_RGB[0]
alb[btn, 1] = BUTTON_RGB[1]
alb[btn, 2] = BUTTON_RGB[2]
# --- region mask: waistband R / legs G / seams+cuffs B ------------------
mask = np.zeros((n, 4), dtype=np.float32)
is_b = (in_cuff | seams) & ~in_band
is_g = ~(in_band | is_b)
mask[in_band, 0] = 1.0
mask[is_b, 2] = 1.0
mask[is_g, 1] = 1.0
return alb, mask
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
evaluate the denim 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], lm)
alb_buf[ysin, xsin] = alb
mask_buf[ysin, xsin] = mask
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, 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] = DENIM_RGB[c] + noise_buf
alb_buf[:, :, 3] = 1.0
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
mask_buf[:, :, 1] = 1.0 # background = legs 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]),
lm, W, H)
tri_count += 1
bm.free()
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
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
albedo_img = _save(alb_buf, f"denim_albedo_{body}", albedo_path)
_save(mask_buf, f"denim_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
return albedo_img
# --------------------------------------------------------------------------
# Parked logo UV2 (pants are not logo-capable; the shader still samples UV2)
# --------------------------------------------------------------------------
def author_parked_uv2(shell):
me = shell.data
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
me.uv_layers.new(name="logo_uv")
me.uv_layers.active = me.uv_layers[0]
bm = bmesh.new()
bm.from_mesh(me)
uvl = bm.loops.layers.uv.get("logo_uv")
for face in bm.faces:
for loop in face.loops:
loop[uvl].uv = (2.0, 2.0)
bm.to_mesh(me)
bm.free()
me.update()
log("logo UV2 authored fully parked (not logo-capable)")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_denim_shell(body_dir, out_dir, body, offset, hem_frac):
crotch_z, _hips_top = probe_hips_bounds(body_dir)
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
weld_boundaries(shell)
zs = [v.co.z for v in shell.data.vertices]
waist_z, ankle_z = max(zs), min(zs)
lm = LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
hem_z = hem_cut(shell, lm, hem_frac)
# Full-length pants hem AT the ankle joint (calf tail); cropped variants
# hem at the cut plane.
ankle_plane = float(lm.leg_z_pts[0]) if hem_frac >= 0.999 else hem_z
waist_plane, ankle_plane = flatten_open_rims(shell, ankle_plane)
base.offset_outward(shell, offset)
waist_flare(shell, waist_plane,
BAND_FRAC * (waist_plane - ankle_plane), WAIST_FLARE_M)
base.solidify(shell, base.CLOTH_THICKNESS_M)
clamp_waist_residue(shell, waist_plane)
# Landmarks reference the CLEAN rims (band under the flattened waist edge,
# cuff above the flattened hem).
lm.waist_z = waist_plane
lm.finalize(ankle_plane)
author_parked_uv2(shell)
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
mask_path = os.path.join(out_dir, f"{body}_mask.png")
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
base.assign_fabric_material(shell, albedo_img)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
def main():
global HEM_FRAC, DENIM_RGB, PLAIN, WAIST_FLARE_M
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-frac F] [--waist-flare M] "
"[--base-rgb r,g,b] [--plain]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = base.PER_BODY_OFFSET_M
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--hem-frac" in argv:
HEM_FRAC = float(argv[argv.index("--hem-frac") + 1])
if "--waist-flare" in argv:
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
if "--base-rgb" in argv:
DENIM_RGB = tuple(
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
if "--plain" in argv:
PLAIN = True
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"denim per-body mode: {len(bodies)} bodies, offset "
f"{offset * 1000:.0f} mm, hem-frac {HEM_FRAC}, plain={PLAIN}")
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_denim_shell(body_dir, out_dir, body, offset, HEM_FRAC)
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()