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>
659 lines
26 KiB
Python
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()
|