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

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

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

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

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

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

659 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):
reach blender run blender_author_denim_pants \
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()