feat(assets): wardrobe wave 2 — footwear, sportswear, swimwear, sweater, cargos, thrds parka, slides (T-1089)

Twelve garments, per-body on all 11 bodies, chromakey-gated (worst clips:
parka 3px, boots 54, sweater/tank 83, sneakers 100 final-geometry, slides
123, swim trunks 132, cargo 138 — all under the 150px gate), previewed:
tank top, sweater (crew-neck via per-body ring-valley probe — first draft
read mock-neck, fixed by measured rim circularization), track jacket
(recolorable sleeve-stripe region), joggers (side-stripe region), cargo
pants, swim trunks, one-piece swimsuit, sneakers (prism-sole), formal
shoes, ankle boots (calf shaft), slides (open strap + sole), and the
hip-length thrds parka — the first canon-branded garment (Braemar
cold-weather cooperative), quilted, logo-capable.

Tops now hem into real hip geometry (the natural seg_torso bottom is a
9-14cm tooth ring — the sweater established the hem-into-hips practice).
Manifest merged by the lead: 24 clothing entries with region/default-tint
metadata. Full modern catalogue: 21 garments across tops/bottoms/feet/
full-body x casual/formal/sport/swim/outerwear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 11:20:18 +02:00
co-authored by Claude Fable 5
parent c765efd54e
commit ded5c62515
517 changed files with 10668 additions and 0 deletions
+740
View File
@@ -0,0 +1,740 @@
"""
blender_author_boots.py (T-1089 wave 2, boots_modern + ankle-boot family)
Authors ANKLE BOOTS as per-body offset shells: both feet PLUS the lower calf
(seg_leg_lower cut to a boot shaft just above the ankle), with a chunky sole
extension and painted eyelets/laces up the shaft. Reuses
blender_author_offset_shell.py (via blender_author_denim_pants.py, imported as
a module) for the shared machinery — scene build, join, offset, solidify,
albedo material, GLB export — plus the two denim REQUIRED PRACTICES for
bottoms/footwear:
* boundary WELD of coincident segment-seam rings (denim.weld_boundaries):
the ankle joins (foot<->leg_lower) duplicate a coincident vert band per
segment; offsetting un-welded rings along diverging normals opens cracks.
* open-rim FLATTENING: the shaft cut leaves a jagged "teeth" ring on each
calf; both rims are pulled down onto one clean shared plane (the deeper
valley of the two, so the boots match) before offsetting.
What this companion adds, as reusable parameters (not hacks):
* --shaft-frac: boot shaft height as a fraction of the ankle->knee calf
span (0.35 cut => rim lands on the lower calf after rim flattening;
larger values give combat/riding variants).
* TOE-BOX MERGE: the skin mesh has individual toes; a boot must not. The
foot region is Laplacian-smoothed pre-offset (mild on the whole foot,
aggressive forward of the ball joint) so the toes fuse into one rounded
leather toe box and anatomical detail (ankle knobs, heel tendon) reads
as boot, not foot. Weights/UVs ride along untouched.
* SKIN CONTAINMENT CLAMP: smoothing can pull the shell inside the skin it
no longer follows (melted toe box vs real toes). A BVH of the ORIGINAL
welded skin is kept, and after the offset every foot-region vert whose
signed distance to the skin is below a minimum clearance is pushed back
out along the skin normal — standoff guaranteed by construction, which
is what the chromakey QA gates on (the runtime does not hide segments).
* FOOT WEIGHT RE-BIND: smoothing + clamping RELOCATE shell verts but leave
them carrying their ORIGIN vertex's bone weights, so material that ends
up hovering over toe N flexes with the bone of toe M — under ball-joint
flexion (Walk push-off, crouch) the shell diverges from the skin beneath
it and the toes poke through (QA/preview evidence, wave 2 run 1/2).
After shaping, every foot-region vert re-copies its vertex-group weights
from the nearest vert of the original welded skin, so the boot flexes
exactly with the anatomy each patch of leather actually covers.
* SOLE geometry as an offset-shell param extension (--sole-drop): the
under-foot surface created by the outward offset is flattened onto a slab
plane sole_drop below the skin sole (solidify adds its thickness back,
so the shipped slab bottom sits exactly sole_drop under the skin), and a
feathered radial LIP (~5 mm) bulges the sole band outward for the chunky
work-boot silhouette.
* --shaft-flare: extra feathered radial stand-off toward the shaft rim.
Serves the same crouch-fold purpose as the denim waist flare AND buys
clearance so full-length pant hems (jeans/formal, 12 mm standoff) tuck
INSIDE the boot shaft instead of z-fighting with it.
* TEXEL-level feature painting (denim technique, boot field): one analytic
field evaluation drives BOTH the painted albedo and the region mask so
they always agree — lace bars + eyelet dots up the front of the shaft,
welt stitching above the sole, padded collar shading at the rim.
Regions (spec): sole -> R, upper + shaft -> G, laces + eyelets -> B.
* a parked logo_uv TEXCOORD_1 layer (boots are not logo-capable, but
toon_garment.gdshader samples UV2 unconditionally).
All cut/mask/paint parameters derive PER BODY from that body's own calf bone
landmarks and measured skin-sole plane, scaled by the calf-span ratio against
average_m (clamped so the child keeps believable, not clown, proportions) —
the same proportional-ratio philosophy as base.derive_thresholds. Per-body
mode only (offset shells author per body, Q-060).
Style pin (T-1089): modern only; identity carried by the texture. The albedo
is painted at mid luma so toon_garment.gdshader's luma-preserving recolor
stays faithful; the brown/black work-street default lives in the manifest
default_tints (sole near-black, upper work brown, laces dark).
Usage (boots_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_boots.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/boots_modern \
[--bodies average_m,child,...] [--offset 0.013] [--shaft-frac 0.30] \
[--shaft-flare 0.008] [--sole-drop 0.020] [--base-rgb r,g,b] [--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 denim companion as a library (which itself imports the base
# offset-shell module). Boot reuse: base machinery + denim's weld/parked-UV2.
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_file_location(
"denim_pants_lib", os.path.join(_HERE, "blender_author_denim_pants.py"))
denim = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(denim)
base = denim.base
log = base.log
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
# --------------------------------------------------------------------------
# Parameters
# --------------------------------------------------------------------------
COVERED_SEGMENTS = [
"seg_foot_l", "seg_foot_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
SHAFT_FRAC = 0.35 # boot shaft = this fraction of the ankle->knee span
BOOT_OFFSET_M = 0.013 # standoff along normals (leather sits off the skin)
SHAFT_FLARE_M = 0.008 # extra radial stand-off at the shaft rim (feathered)
SOLE_DROP_M = 0.020 # chunky sole slab depth below the skin sole (spec)
SOLE_LIP_M = 0.008 # radial sole bulge (chunky work-boot lip)
SOLE_LIP_TOP_M = 0.020 # lip feather reaches this far above the skin sole
TEX_SIZE = 1024 # albedo + mask resolution (painted laces need >512)
NOISE_SEED = 3089
# Toe-box merge (pre-offset Laplacian smoothing).
FOOT_SMOOTH_ITERS = 4 # mild pass over the whole foot (below the ankle)
FOOT_SMOOTH_FACTOR = 0.5
TOE_SMOOTH_ITERS = 10 # aggressive pass forward of the ball joint
TOE_SMOOTH_FACTOR = 1.0
TOE_ZONE_BALL_FRAC = 0.80 # toe zone starts at this fraction of the ball-y
# Skin containment clamp (post-offset).
CLAMP_CLEAR_FRAC = 1.0 # minimum clearance as a fraction of the offset.
# At 1.0 the clamp restores the full standoff: the
# clamp moves VERTS, and a face spanning two clamped
# verts can still dip toward the high-frequency skin
# toes between them (QA/preview evidence: toe tips
# poked the melted toe box at 0.75). Higher values
# (1.15 tried) buy little once the foot weight
# re-bind aligns flexion, and read as a lumpy box.
CLAMP_ITERS = 3
# Painted-detail metrics (metres on average_m; scaled by the calf-span ratio).
LACE_PITCH_M = 0.017 # vertical distance between lace bars
LACE_BAR_HW_M = 0.0026 # half-height of a painted lace bar
LACE_PANEL_HW_M = 0.015 # half arc-width of the lace panel
EYELET_R_M = 0.0032 # eyelet dot radius (at the panel edges)
LACE_LO_FRAC = 0.0 # panel starts at the ankle joint (shaft only)
COLLAR_H_M = 0.014 # padded collar band at the shaft rim (albedo shade)
SOLE_TOP_M = 0.014 # R region reaches this far above the skin sole
WELT_OFF_M = 0.0035 # welt stitch line offset above the sole top
SEAM_HW_M = 0.0018 # painted stitch line half-width
TOECAP_STITCH = True # painted toe-cap stitch line at the ball joint
# Albedo tones (sRGB floats). Mid-luma leather so the toon_garment luma
# recolor keeps its dynamic range; hue defaults come from manifest tints.
LEATHER_RGB = (0.58, 0.44, 0.32) # light tan work leather
SOLE_RGB = (0.30, 0.29, 0.28) # darker rubber (low luma -> reads black)
LACE_RGB = (0.24, 0.21, 0.19) # dark laces
EYELET_RGB = (0.85, 0.78, 0.60) # bright metal eyelet glint
WELT_RGB = (0.78, 0.62, 0.40) # welt stitching thread
PANEL_SHADE = 0.90 # lace-panel background darkening
COLLAR_SHADE = 0.86 # padded collar darkening
ALBEDO_NOISE = 0.020 # +/- grain jitter
PLAIN = False # --plain: skip laces/eyelets/welt paint
# Reference proportions (average_m) the scale ratio is anchored to.
_REF_CALF_SPAN = 0.4559 # calf head z (0.5424) - calf tail z (0.0865)
# --------------------------------------------------------------------------
# Per-body landmarks
# --------------------------------------------------------------------------
class BootLandmarks:
"""Cut/mask/paint parameters from one body's calf bones + skin-sole plane."""
def __init__(self, armature, skin_min_z):
bones = armature.data.bones
calf_l = bones.get("calf_l")
calf_r = bones.get("calf_r")
if calf_l is None or calf_r is None:
raise RuntimeError("calf_l/calf_r missing — not the 65-bone rig?")
self.ankle_z = (calf_l.tail_local.z + calf_r.tail_local.z) / 2.0
self.knee_z = (calf_l.head_local.z + calf_r.head_local.z) / 2.0
self.skin_min_z = skin_min_z
self.calf_span = self.knee_z - self.ankle_z
# Detail scale: proportional to the calf span, clamped so small bodies
# keep believable (not clown, not doll) sole/lace metrics.
self.s = min(max(self.calf_span / _REF_CALF_SPAN, 0.55), 1.15)
# Per-z leg axis control points (ankle -> knee) for the +x leg;
# the right leg mirrors via the x sign. np.interp clamps outside.
self.leg_z_pts = np.array([calf_l.tail_local.z, calf_l.head_local.z])
self.leg_x_pts = np.array(
[abs(calf_l.tail_local.x), abs(calf_l.head_local.x)])
self.leg_y_pts = np.array([calf_l.tail_local.y, calf_l.head_local.y])
self.shaft_top_z = self.ankle_z + SHAFT_FRAC * self.calf_span
self.rim_z = self.shaft_top_z # finalized after rim flattening
# Ball joint (toe-box hinge): forward distance on the front axis.
ball = bones.get("ball_l")
self.ball_front = (ball.head_local.y * FRONT_Y_SIGN
if ball is not None else None)
def finalize(self, rim_z):
self.rim_z = rim_z
self.sole_top_z = self.skin_min_z + SOLE_TOP_M * self.s
self.lace_lo = self.ankle_z - LACE_LO_FRAC * (self.ankle_z - self.skin_min_z)
self.lace_hi = self.rim_z - 0.7 * COLLAR_H_M * self.s
log(f"landmarks: ankle={self.ankle_z:.3f} knee={self.knee_z:.3f} "
f"sole={self.skin_min_z:.3f} rim={self.rim_z:.3f} s={self.s:.2f} "
f"sole_top={self.sole_top_z:.3f} "
f"laces z=[{self.lace_lo:.3f},{self.lace_hi:.3f}]")
# --------------------------------------------------------------------------
# Geometry: shaft cut + rim flatten + flare + sole
# --------------------------------------------------------------------------
def build_skin_bvh(shell):
"""BVH of the welded skin BEFORE any shaping — the containment reference.
Returns (bvh, bm); the bmesh must stay alive as long as the BVH is used.
"""
from mathutils.bvhtree import BVHTree
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.normal_update()
return BVHTree.FromBMesh(bm), bm
def containment_clamp(shell, skin_bvh, min_clear, z_max):
"""Push shell verts back outside the original skin (post-offset).
The toe-box merge can leave the smoothed shell INSIDE the real skin toes;
any vert (below z_max) whose signed distance to the skin surface is less
than min_clear is moved to min_clear along the skin normal. Iterated,
because in concave spots (toe crevices) the first push can land near
another skin face."""
me = shell.data
total = 0
for it in range(CLAMP_ITERS):
moved = 0
for v in me.vertices:
if v.co.z > z_max:
continue
loc, nrm, _idx, _dist = skin_bvh.find_nearest(v.co)
if loc is None:
continue
if (v.co - loc).dot(nrm) < min_clear:
v.co = loc + nrm * min_clear
moved += 1
total += moved
if moved == 0:
break
me.update()
log(f"containment clamp: {total} vert pushes "
f"(min clearance {min_clear * 1000:.1f} mm)")
def transfer_foot_weights(shell, skin_bm, skin_bvh, z_max):
"""Re-copy foot-region vertex weights from the nearest ORIGINAL skin vert.
Smoothing + clamping relocate shell verts while they keep their origin
vert's weights, so leather hovering over toe N can flex with toe M's bone
— under ball flexion the shell then diverges from the skin it covers and
the toes poke through. Copying each relocated vert's weights from the
nearest vert of the original welded skin re-aligns material to anatomy.
Group indices match by construction (same object, groups untouched).
"""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
dl = bm.verts.layers.deform.verify()
sdl = skin_bm.verts.layers.deform.verify()
skin_bm.faces.ensure_lookup_table()
rebound = 0
for v in bm.verts:
if v.co.z > z_max:
continue
loc, _nrm, fidx, _dist = skin_bvh.find_nearest(v.co)
if loc is None:
continue
face = skin_bm.faces[fidx]
sv = min(face.verts, key=lambda fv: (fv.co - v.co).length_squared)
src = sv[sdl]
dst = v[dl]
dst.clear()
for g, w in src.items():
dst[g] = w
rebound += 1
bm.to_mesh(me)
bm.free()
me.update()
log(f"foot weight re-bind: {rebound} verts re-copied from nearest skin")
def toe_merge(shell, lm):
"""Fuse the skin toes into one rounded boot toe box (pre-offset).
Two Laplacian passes: a mild one over the whole foot (below the ankle)
rounds anatomical detail into leather, an aggressive one forward of the
ball joint melts the individual toes together. Vertex weights and UVs are
untouched, so skinning and texel painting are unaffected; the outward
offset afterwards restores the lost girth."""
bm = bmesh.new()
bm.from_mesh(shell.data)
foot = [v for v in bm.verts if v.co.z < lm.ankle_z]
for _ in range(FOOT_SMOOTH_ITERS):
bmesh.ops.smooth_vert(bm, verts=foot, factor=FOOT_SMOOTH_FACTOR,
use_axis_x=True, use_axis_y=True, use_axis_z=True)
n_toe = 0
if lm.ball_front is not None:
toe_y = lm.ball_front * TOE_ZONE_BALL_FRAC
toes = [v for v in foot if v.co.y * FRONT_Y_SIGN > toe_y]
n_toe = len(toes)
for _ in range(TOE_SMOOTH_ITERS):
bmesh.ops.smooth_vert(bm, verts=toes, factor=TOE_SMOOTH_FACTOR,
use_axis_x=True, use_axis_y=True,
use_axis_z=True)
else:
log("WARNING: ball_l bone missing — toe box keeps skin toes")
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"toe merge: smoothed {len(foot)} foot verts "
f"({FOOT_SMOOTH_ITERS}x{FOOT_SMOOTH_FACTOR}), "
f"{n_toe} toe verts ({TOE_SMOOTH_ITERS}x{TOE_SMOOTH_FACTOR})")
def shaft_cut(shell, lm):
"""Trim the calves above the boot-shaft plane (bone-derived)."""
bm = bmesh.new()
bm.from_mesh(shell.data)
doomed = [v for v in bm.verts if v.co.z > lm.shaft_top_z]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"shaft cut at z={lm.shaft_top_z:.3f}: removed {len(doomed)} verts")
def flatten_shaft_rims(shell, lm):
"""Pull both shaft-rim teeth rings onto ONE shared clean plane (pre-offset).
The plane cut follows mesh topology, so each rim is a jagged ring of
teeth. Both rings flatten DOWN to the common valley (deepest notch of
either ring) — a clean straight rim, identical height on both boots, no
invented coverage. Any stray open-boundary verts below the shaft (weld
leftovers) are left alone and reported. Returns the rim plane z.
"""
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:
boundary.update(v.index for v in e.verts)
low_thresh = lm.ankle_z + 0.25 * (lm.shaft_top_z - lm.ankle_z)
rims = [i for i in boundary if bm.verts[i].co.z > low_thresh]
stray = len(boundary) - len(rims)
if not rims:
bm.free()
raise RuntimeError("no shaft rim boundary verts found")
valley = min(bm.verts[i].co.z for i in rims)
teeth = max(bm.verts[i].co.z for i in rims) - valley
for i in rims:
bm.verts[i].co.z = valley
bm.to_mesh(me)
bm.free()
me.update()
log(f"flattened shaft rims: {len(rims)} verts, teeth {teeth:.3f} m "
f"-> z={valley:.3f}"
+ (f" (WARNING: {stray} stray low boundary verts left)" if stray else ""))
return valley
def shaft_flare(shell, lm, rim_z, flare):
"""Extra RADIAL stand-off toward the shaft rim, feathered from the ankle
(post-offset, pre-solidify). Buys pant-hem clearance + chunky read."""
if flare <= 0.0:
return
span = max(rim_z - lm.ankle_z, 1e-6)
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.normal_update()
n = 0
for v in bm.verts:
if v.co.z > lm.ankle_z:
t = min((v.co.z - lm.ankle_z) / span, 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"shaft flare: {n} verts, +{flare * 1000:.1f} mm radial at rim")
def sole_shape(shell, lm, offset, sole_drop):
"""Chunky sole (post-offset, pre-solidify): feathered radial LIP around
the sole band, then the under-foot offset surface flattened onto a slab
plane. Solidify(use_rim) then grows its thickness outward (downward
there), landing the shipped slab bottom exactly sole_drop below the skin
sole."""
lip_top = lm.skin_min_z + SOLE_LIP_TOP_M * lm.s
lip = SOLE_LIP_M * lm.s
slab_z = lm.skin_min_z - (sole_drop * lm.s - base.CLOTH_THICKNESS_M)
feather = max(lip_top - lm.skin_min_z, 1e-6)
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.normal_update()
n_lip = 0
for v in bm.verts:
if v.co.z < lip_top:
t = min((lip_top - v.co.z) / feather, 1.0)
nx, ny = v.normal.x, v.normal.y
mag = (nx * nx + ny * ny) ** 0.5
if mag > 1e-6:
v.co.x += lip * t * nx / mag
v.co.y += lip * t * ny / mag
n_lip += 1
n_flat = 0
for v in bm.verts:
if v.co.z < lm.skin_min_z:
v.co.z = slab_z
n_flat += 1
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"sole: lip +{lip * 1000:.1f} mm on {n_lip} verts, "
f"{n_flat} under-sole verts flattened -> z={slab_z:.3f} "
f"(shipped bottom {sole_drop * lm.s * 1000:.0f} mm below skin sole)")
def clamp_rim_residue(shell, rim_z):
"""Post-solidify safety clamp: verts still above the rim plane (solidify
displacement on multi-triangle teeth) get squashed onto it."""
me = shell.data
n = 0
for v in me.vertices:
if v.co.z > rim_z:
v.co.z = rim_z
n += 1
me.update()
if n:
log(f"clamped {n} residual rim verts -> {rim_z:.3f}")
# --------------------------------------------------------------------------
# Boot feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _boot_field(px, py, pz, lm):
"""Evaluate boot features at texel 3D positions (numpy arrays)."""
s = lm.s
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
# Arc distance from the front meridian around the per-z leg axis —
# constant metric width at any girth, mirrors correctly on both boots.
arc_front = np.arccos(np.clip(dy * FRONT_Y_SIGN / r, -1.0, 1.0)) * r
in_sole = pz <= lm.sole_top_z
in_lace_z = (pz > lm.lace_lo) & (pz < lm.lace_hi) & ~in_sole
panel = in_lace_z & (arc_front < LACE_PANEL_HW_M * s)
pitch = LACE_PITCH_M * s
ph = np.mod(pz - lm.lace_lo, pitch)
laces = panel & (np.abs(ph - 0.5 * pitch) < LACE_BAR_HW_M * s)
eyelets = (
in_lace_z
& (np.abs(arc_front - LACE_PANEL_HW_M * s) < EYELET_R_M * s)
& (np.abs(ph - 0.5 * pitch) < EYELET_R_M * s)
)
welt = (~in_sole) & (
np.abs(pz - (lm.sole_top_z + WELT_OFF_M * s)) < SEAM_HW_M * s)
if TOECAP_STITCH and lm.ball_front is not None:
welt = welt | (
(~in_sole) & (pz < lm.lace_lo)
& (np.abs(py * FRONT_Y_SIGN - lm.ball_front) < SEAM_HW_M * s))
collar = (~in_sole) & (pz > lm.rim_z - COLLAR_H_M * s)
return in_sole, panel, laces, eyelets, welt, collar
def _paint_texels(px, py, pz, noise, lm):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
n = px.shape[0]
in_sole, panel, laces, eyelets, welt, collar = _boot_field(px, py, pz, lm)
# --- albedo ------------------------------------------------------------
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = LEATHER_RGB[c] + noise
alb[:, 3] = 1.0
alb[collar, :3] *= COLLAR_SHADE
if not PLAIN:
alb[panel, :3] *= PANEL_SHADE
for c in range(3):
alb[welt, c] = WELT_RGB[c]
for c in range(3):
alb[in_sole, c] = SOLE_RGB[c] + noise[in_sole]
if not PLAIN:
for c in range(3):
alb[laces, c] = LACE_RGB[c]
alb[eyelets, c] = EYELET_RGB[c]
# --- region mask: sole R / upper+shaft G / laces+eyelets B ---------------
mask = np.zeros((n, 4), dtype=np.float32)
is_b = (laces | eyelets) & ~in_sole if not PLAIN else np.zeros(n, dtype=bool)
is_g = ~(in_sole | is_b)
mask[in_sole, 0] = 1.0
mask[is_b, 2] = 1.0
mask[is_g, 1] = 1.0
return alb, mask
# --------------------------------------------------------------------------
# UV0 rasterization (denim technique: one pass paints albedo + 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 boot 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] = LEATHER_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 = upper 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"boot_albedo_{body}", albedo_path)
_save(mask_buf, f"boot_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
return albedo_img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_boot_shell(body_dir, out_dir, body, offset, sole_drop):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
denim.weld_boundaries(shell) # ankle seams: foot <-> leg_lower
skin_min_z = min(v.co.z for v in shell.data.vertices)
lm = BootLandmarks(armature, skin_min_z)
# Containment reference: the ORIGINAL welded skin (real toes), captured
# before any shaping. The bmesh must outlive the BVH queries.
skin_bvh, skin_bm = build_skin_bvh(shell)
toe_merge(shell, lm)
shaft_cut(shell, lm)
rim_z = flatten_shaft_rims(shell, lm)
base.offset_outward(shell, offset)
# Toe-box merge can leave the smoothed+offset shell inside the real skin
# toes; push foot-region verts back out to the minimum clearance, then
# re-align their bone weights to the anatomy they now cover (flex fix).
containment_clamp(shell, skin_bvh, CLAMP_CLEAR_FRAC * offset, lm.ankle_z)
transfer_foot_weights(shell, skin_bm, skin_bvh, lm.ankle_z)
skin_bm.free()
shaft_flare(shell, lm, rim_z, SHAFT_FLARE_M * lm.s)
sole_shape(shell, lm, offset, sole_drop)
base.solidify(shell, base.CLOTH_THICKNESS_M)
clamp_rim_residue(shell, rim_z)
lm.finalize(rim_z)
denim.author_parked_uv2(shell) # boots are not logo-capable
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 SHAFT_FRAC, SHAFT_FLARE_M, LEATHER_RGB, PLAIN
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] [--shaft-frac F] [--shaft-flare M] "
"[--sole-drop M] [--base-rgb r,g,b] [--plain]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = BOOT_OFFSET_M
sole_drop = SOLE_DROP_M
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--shaft-frac" in argv:
SHAFT_FRAC = float(argv[argv.index("--shaft-frac") + 1])
if "--shaft-flare" in argv:
SHAFT_FLARE_M = float(argv[argv.index("--shaft-flare") + 1])
if "--sole-drop" in argv:
sole_drop = float(argv[argv.index("--sole-drop") + 1])
if "--base-rgb" in argv:
LEATHER_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"boots per-body mode: {len(bodies)} bodies, offset "
f"{offset * 1000:.0f} mm, shaft-frac {SHAFT_FRAC}, "
f"sole-drop {sole_drop * 1000:.0f} mm, 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_boot_shell(body_dir, out_dir, body, offset, sole_drop)
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()
@@ -0,0 +1,502 @@
"""
blender_author_cargo_pants.py (T-1089 wave 2, cargo_pants)
Authors full-length CARGO pants as per-body offset shells. Reuses
blender_author_offset_shell.py as the base library (scene build, join,
offset, solidify, GLB export) and blender_author_denim_pants.py as the
proven bottoms companion (boundary WELD of coincident segment-seam rings,
open-rim FLATTENING onto clean planes, per-body LegLandmarks, waist flare,
hem cut, parked logo UV2) — the denim script's utilities are imported as a
module, not re-derived.
What cargo adds, as reusable parameters:
* --straight: STRAIGHT-FIT boost — extra radial stand-off away from the
per-z leg axis, ramping 0 at the knee to the full value at the ankle, so
the lower leg does not taper with the calf (straight silhouette; also
buys lower-leg clip clearance). Applied post-offset, pre-solidify.
* TEXEL-level cargo feature painting (same one-field-drives-both design as
denim: painted albedo and region mask always agree):
- albedo: large outer-thigh CARGO POCKETS with button-down FLAPS
(filled panels, border stitching, centre pleat, two flap
buttons), outseam/inseam side seams, centre-front fly
stitch, belt loops + waist button, waistband and hem border
stitching — flat tone-on-tone utility look, identity
carried by the texture (style pin: modern only).
- mask: waistband -> R, legs -> G, pockets + flaps -> B
(spec: waistband=R, legs=G, pockets+flaps=B).
A SIGNED azimuth arc around the per-z leg axis (0 at the outer side,
positive toward the front) places the pockets slightly forward-of-side
and the flap buttons symmetrically — the same analytic-field approach as
the denim script, generalised from unsigned to signed arc.
* pocket proportions anchor to each body's own THIGH span (crotch ->
knee) and hip half-width, so the pockets stay proportional across all
11 bodies (child included) — the base.derive_thresholds philosophy.
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r; natural
boundaries give the waist opening and ankle hems. Regions are painted, not
modelled — no 3D pocket geometry (texture carries identity).
Usage (cargo_pants reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_cargo_pants.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/cargo_pants \
[--bodies average_m,child,...] [--offset 0.013] [--hem-frac 1.0] \
[--waist-flare 0.007] [--straight 0.010] [--base-rgb 0.36,0.37,0.29] \
[--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 + the denim bottoms companion
# (weld / rim-flatten / flare / landmarks utilities live there)
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load(name, fname):
spec = importlib.util.spec_from_file_location(
name, os.path.join(_HERE, fname))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
base = _load("offset_shell_base", "blender_author_offset_shell.py")
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
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
OFFSET_M = 0.013 # cargo sits a touch looser than jeans (0.012)
STRAIGHT_M = 0.010 # straight-fit radial boost at the ankle (0 at knee)
WAIST_FLARE_M = 0.007 # waistband rim stand-off (deep-crouch mitigation,
# proven on jeans)
TEX_SIZE = 1024 # painted pockets/seams need >512
# Vertical proportions — fractions of the garment span (waist_z - hem_z),
# via denim.LegLandmarks.finalize (BAND/CUFF/SEAM fractions proven on jeans).
# Pocket proportions — fractions of the THIGH span (crotch_z - knee_z).
FLAP_TOP_FRAC = 0.18 # flap top below the crotch
FLAP_H_FRAC = 0.14 # flap height
POCKET_BOT_FRAC = 0.74 # pocket bottom below the crotch
# Horizontal proportions — signed azimuth ARC around the per-z leg axis
# (metres on the reference body, scaled by the body's hip ratio lm.sh).
POCKET_HALF_ARC_M = 0.058 # pocket half-width
FLAP_EXTRA_ARC_M = 0.007 # flap overhangs the pocket by this much per side
POCKET_FWD_BIAS_M = 0.010 # pocket centre sits slightly forward of the outseam
BUTTON_R_M = 0.0075 # flap button radius
LOOP_X_FRACS = (0.55, 1.30) # belt-loop |x| positions (fractions of hip_x)
LOOP_W_M = 0.016 # belt loop width (m, scaled by hip ratio)
# Utility olive/grey style (sRGB floats; flat toon-friendly, tone-on-tone).
CARGO_RGB = (0.360, 0.370, 0.290) # utility olive-grey
THREAD_RGB = (0.225, 0.235, 0.185) # tone-on-tone darker stitching
BUTTON_RGB = (0.170, 0.160, 0.140) # matte button
ALBEDO_NOISE = 0.020 # +/- woven jitter
POCKET_SHADE = 0.93 # pocket panel albedo darkening
FLAP_SHADE = 0.85 # flap panel albedo darkening
LOOP_SHADE = 0.80 # belt-loop albedo darkening
PLAIN = False # --plain: skip pockets/stitch/button paint
NOISE_SEED = 3089
# --------------------------------------------------------------------------
# Geometry: straight-fit boost (cargo's reusable fit parameter)
# --------------------------------------------------------------------------
def straight_boost(shell, lm, boost):
"""Extra RADIAL stand-off away from the per-z leg axis below the knee,
ramping linearly from 0 at the knee to `boost` at the ankle. The body
calf tapers; pushing the shell out progressively keeps the pant leg
straight (cargo silhouette) instead of hugging the calf. Applied
post-offset, pre-solidify; weights/UVs are untouched."""
if boost <= 0.0:
return
knee_z = float(lm.leg_z_pts[1])
ankle_z = float(lm.leg_z_pts[0])
span = max(knee_z - ankle_z, 1e-6)
bm = bmesh.new()
bm.from_mesh(shell.data)
n = 0
for v in bm.verts:
z = v.co.z
if z >= knee_z:
continue
t = min((knee_z - z) / span, 1.0)
side = 1.0 if v.co.x >= 0.0 else -1.0
cx = side * float(np.interp(z, lm.leg_z_pts, lm.leg_x_pts))
cy = float(np.interp(z, lm.leg_z_pts, lm.leg_y_pts))
dx = v.co.x - cx
dy = v.co.y - cy
r = (dx * dx + dy * dy) ** 0.5
if r > 1e-6:
v.co.x += boost * t * dx / r
v.co.y += boost * t * dy / r
n += 1
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"straight-fit boost: {n} verts, +{boost * 1000:.1f} mm radial at "
f"ankle (ramped from knee z={knee_z:.3f})")
# --------------------------------------------------------------------------
# Cargo feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _cargo_field(px, py, pz, lm):
"""Evaluate cargo features at texel 3D positions (numpy arrays).
Returns a dict of bool arrays. `pocket`/`flap` feed the mask B channel;
the stitch/fill features feed the albedo only.
"""
w2 = lm.seam_w * 0.5
front = py * FRONT_Y_SIGN > 0.004
in_band = pz >= lm.band_z
mid = (~in_band) & (pz > lm.hem_z + 0.5 * lm.cuff_h)
# Per-z leg axis, mirrored by x sign; SIGNED azimuth arc around it
# (0 at the outer side, positive toward the front, +/-pi*r at the inseam).
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
theta = np.arctan2(dy * FRONT_Y_SIGN / r, np.clip(dx * side / r, -1.0, 1.0))
sarc = theta * r # signed arc distance from the outer direction
# Side seams: outseam on the outer azimuth; inseam opposite, below crotch.
outseam = mid & (np.abs(sarc) < w2)
arc_in = (np.pi - np.abs(theta)) * r
inseam = mid & (arc_in < w2) \
& (pz < lm.crotch_z - 0.01 * lm.span / denim._REF_SPAN)
# Centre-front fly stitch (slightly off-centre, like the jeans J-front).
fly = front & mid & (np.abs(px - 0.012 * lm.sh) < w2) \
& (pz > lm.crotch_z + 0.015 * lm.span / denim._REF_SPAN)
# --- cargo pocket + flap (outer thigh, slightly forward-of-side) --------
knee_z = float(lm.leg_z_pts[1])
thigh = max(lm.crotch_z - knee_z, 1e-6)
flap_top = lm.crotch_z - FLAP_TOP_FRAC * thigh
flap_bot = flap_top - FLAP_H_FRAC * thigh
pocket_bot = lm.crotch_z - POCKET_BOT_FRAC * thigh
p_arc = POCKET_HALF_ARC_M * lm.sh
f_arc = p_arc + FLAP_EXTRA_ARC_M * lm.sh
a = sarc - POCKET_FWD_BIAS_M * lm.sh # pocket-centred signed arc
pocket = (np.abs(a) < p_arc) & (pz <= flap_top) & (pz >= pocket_bot)
flap = (np.abs(a) < f_arc) & (pz <= flap_top) & (pz >= flap_bot)
# Pocket border stitching (sides + bottom), centre pleat, flap edge.
p_side = (np.abs(np.abs(a) - p_arc) < w2) & (pz <= flap_top) \
& (pz >= pocket_bot)
p_bottom = (np.abs(pz - pocket_bot) < w2) & (np.abs(a) < p_arc)
pleat = (np.abs(a) < w2) & (pz < flap_bot - 2.0 * w2) & (pz >= pocket_bot)
f_edge = flap & ((np.abs(np.abs(a) - f_arc) < w2)
| (np.abs(pz - flap_bot) < w2))
pkt_stitch = p_side | p_bottom | pleat | f_edge
# Two flap buttons, symmetric about the pocket centre.
btn_r = BUTTON_R_M * lm.sh
btn_z = flap_bot + 0.30 * (flap_top - flap_bot)
buttons = np.zeros_like(front)
for bx in (-0.45 * f_arc, 0.45 * f_arc):
buttons |= np.hypot(a - bx, pz - btn_z) < btn_r
# Border stitching: waistband seam + hem stitch.
wstitch = np.abs(pz - lm.band_z) < w2
hemstitch = np.abs(pz - lm.cuff_top) < w2
return {
"front": front, "in_band": in_band,
"seams": outseam | inseam | fly,
"wstitch": wstitch, "hemstitch": hemstitch,
"pocket": pocket, "flap": flap,
"pkt_stitch": pkt_stitch, "buttons": buttons,
}
def _paint_texels(px, py, pz, noise, lm):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
n = px.shape[0]
f = _cargo_field(px, py, pz, lm)
# --- albedo -------------------------------------------------------------
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = CARGO_RGB[c] + noise
alb[:, 3] = 1.0
if not PLAIN:
# Belt loops: darkened 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 |= (~f["front"]) & (np.abs(px) < loop_w * 0.5) # centre-back
loops &= f["in_band"]
alb[loops, :3] *= LOOP_SHADE
# Panel fills first, stitch lines on top.
alb[f["pocket"] & ~f["flap"], :3] *= POCKET_SHADE
alb[f["flap"], :3] *= FLAP_SHADE
thread = f["seams"] | f["wstitch"] | f["hemstitch"] | f["pkt_stitch"]
alb[thread, 0] = THREAD_RGB[0]
alb[thread, 1] = THREAD_RGB[1]
alb[thread, 2] = THREAD_RGB[2]
# Flap buttons + waist button.
btn = f["buttons"] | (f["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 / pockets+flaps B -----------------
mask = np.zeros((n, 4), dtype=np.float32)
is_b = (f["pocket"] | f["flap"]) & ~f["in_band"]
is_g = ~(f["in_band"] | is_b)
mask[f["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 cargo field, write albedo + mask together (same rasterizer
contract as the denim companion, pointed at the cargo painter)."""
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] = CARGO_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"cargo_albedo_{body}", albedo_path)
_save(mask_buf, f"cargo_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
return albedo_img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_cargo_shell(body_dir, out_dir, body, offset, hem_frac):
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
denim.weld_boundaries(shell)
zs = [v.co.z for v in shell.data.vertices]
waist_z, ankle_z = max(zs), min(zs)
lm = denim.LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
hem_z = denim.hem_cut(shell, lm, hem_frac)
ankle_plane = float(lm.leg_z_pts[0]) if hem_frac >= 0.999 else hem_z
waist_plane, ankle_plane = denim.flatten_open_rims(shell, ankle_plane)
base.offset_outward(shell, offset)
straight_boost(shell, lm, STRAIGHT_M)
denim.waist_flare(shell, waist_plane,
denim.BAND_FRAC * (waist_plane - ankle_plane),
WAIST_FLARE_M)
base.solidify(shell, base.CLOTH_THICKNESS_M)
denim.clamp_waist_residue(shell, waist_plane)
# Landmarks reference the CLEAN rims (band under the flattened waist edge,
# hem stitch above the flattened ankle rim).
lm.waist_z = waist_plane
lm.finalize(ankle_plane)
denim.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, CARGO_RGB, PLAIN, WAIST_FLARE_M, STRAIGHT_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] [--straight M] "
"[--base-rgb r,g,b] [--plain]")
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-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 "--straight" in argv:
STRAIGHT_M = float(argv[argv.index("--straight") + 1])
if "--base-rgb" in argv:
CARGO_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"cargo per-body mode: {len(bodies)} bodies, offset "
f"{offset * 1000:.0f} mm, straight {STRAIGHT_M * 1000:.0f} mm, "
f"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_cargo_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()
@@ -0,0 +1,511 @@
"""
blender_author_joggers.py (T-1089 wave 2, joggers_modern + sweatpant family)
Authors JOGGERS/sweatpants as per-body offset shells: hips + full legs,
tapered toward the ankle into snug cuff bands, painted waist drawstring, and
a track SIDE STRIPE carried as its own tint region. Reuses
blender_author_offset_shell.py (via blender_author_denim_pants.py's module
instance) for scene build / join / solidify / export, and reuses the denim
companion's REQUIRED lower-body practices directly (not re-derived):
* boundary WELD of coincident segment-seam rings before offsetting
(denim.weld_boundaries) — un-welded rings offset apart along diverging
normals and open cracks at the waist/knee joins.
* open-rim FLATTENING (denim.flatten_open_rims) — the segment splitter
leaves 4.5-7.6 cm jagged teeth at the waist/ankle rims; boundary verts are
pulled onto clean planes pre-offset. The flattened ankle plane IS the
jogger cuff hem.
* waist flare + post-solidify residue clamp (denim.waist_flare,
denim.clamp_waist_residue) — deep-crouch waist-fold clip mitigation.
* per-body leg-axis landmarks (denim.LegLandmarks) — thigh_l/calf_l control
points drive azimuth math for the side stripe and the cuff/band rib paint,
so every parameter derives from the body's own bones (Q-060 per-body mode).
What this companion adds, as reusable parameters (not hacks):
* TAPERED variable offset — constant baggy standoff (--offset) above the
knee, smoothstep taper to --mid-offset at the cuff top, then a short
feathered step down to the snug --cuff-offset inside the cuff band: the
sweatpant silhouette (baggy thigh -> tapered shin -> gripping cuff).
* ankle CUFF band (--cuff-frac of the garment span) — its own mask region
(A channel) with painted knit RIB (azimuth-arc alternating shades, constant
metric rib width via the per-z leg axis).
* SIDE STRIPE (--stripe-width metres, scaled by hip width) — constant-width
azimuth band along the outer leg from waistband to cuff, painted bright
AND masked to its own region (B channel) for independent tinting.
* painted elastic waistband (subtle vertical rib) + centre-front DRAWSTRING:
two eyelets and two hanging cords with a slight outward slant (albedo
only; dark cords stay legible under the luma-scaled region tint).
Regions (RGBA mask, toon_garment.gdshader): waistband -> R (tint_0),
legs -> G (tint_1), side stripe -> B (tint_2), ankle cuffs -> A (tint_3).
A parked logo_uv TEXCOORD_1 layer is authored (denim.author_parked_uv2) —
not logo-capable, but the shader samples UV2 unconditionally.
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r. Natural
boundaries give the waist opening and ankle hems for free. Per-body mode only
(offset shells author per body, Q-060).
Usage (joggers_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_joggers.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/joggers_modern \
[--bodies average_m,child,...] [--offset 0.014] [--mid-offset 0.010] \
[--cuff-offset 0.0065] [--band-frac 0.055] [--cuff-frac 0.075] \
[--stripe-width 0.034] [--fabric 0.55,0.56,0.58] \
[--stripe-rgb 0.88,0.89,0.91] [--waist-flare 0.007] [--seed N] [--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 denim companion as a module — it carries the shared lower-body
# practices (weld, rim flatten, waist flare, clamp, landmarks, parked UV2) and
# its own instance of the base offset-shell library.
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_file_location(
"denim_pants", os.path.join(_HERE, "blender_author_denim_pants.py"))
denim = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(denim)
base = denim.base # single shared base-module instance
log = base.log
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
# --------------------------------------------------------------------------
# Parameters (defaults = joggers_modern)
# --------------------------------------------------------------------------
COVERED_SEGMENTS = [
"seg_hips",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
TEX_SIZE = 1024 # albedo + mask resolution (rib + cords need >512)
WAIST_FLARE_M = 0.007 # denim-proven deep-crouch waist-fold mitigation
# Tapered-offset profile (metres).
OFFSET_HI_M = 0.014 # hips/thigh standoff — baggy sweatpant volume
OFFSET_MID_M = 0.010 # taper target at the cuff top (shin)
OFFSET_CUFF_M = 0.0065 # snug cuff standoff (ankles barely deform)
CUFF_STEP_W_M = 0.010 # feather width of the taper->cuff step
# Vertical proportions — fractions of the garment span (waist_z - hem_z).
BAND_FRAC = 0.055 # elastic waistband height (R region)
CUFF_FRAC = 0.075 # knit cuff band height (A region) — taller than a hem
SEAM_W_FRAC = 0.008 # painted border-stitch line width
CORD_DROP_FRAC = 0.050 # drawstring cord length below the waistband
CORD_SLANT = 0.18 # outward cord slant (dx per unit hang depth)
# Horizontal / arc-metric proportions (scaled by hip half-width ratio lm.sh).
STRIPE_W_M = 0.034 # side stripe metric width (B region)
RIB_PERIOD_M = 0.009 # cuff knit-rib period (arc metres)
BAND_RIB_PERIOD_M = 0.006 # waistband elastic-rib period (arc metres)
CORD_X_M = 0.016 # drawstring eyelet/cord |x| at the band
CORD_W_M = 0.005 # cord width
EYELET_R_M = 0.0045 # eyelet dot radius
# Fleece style (sRGB floats; flat toon-friendly, texture carries identity).
FLEECE_RGB = (0.55, 0.56, 0.58) # heather grey, luma ~0.56 (tint-faithful)
STRIPE_RGB = (0.88, 0.89, 0.91) # near-white track stripe
CORD_RGB = (0.17, 0.17, 0.19) # charcoal drawstring
ALBEDO_NOISE = 0.020 # +/- fleece jitter
BAND_SHADE = 0.94 # waistband overall darkening
CUFF_SHADE = 0.96 # cuff overall darkening
RIB_LO = 0.86 # cuff rib dark-stripe factor
BAND_RIB_LO = 0.94 # waistband rib dark-stripe factor
STITCH_SHADE = 0.80 # band/cuff border-stitch darkening
PLAIN = False # --plain: skip drawstring/rib/stitch paint
NOISE_SEED = 2090 # distinct from denim (2089)
# --------------------------------------------------------------------------
# Joggers landmark finalization (denim.LegLandmarks carries the leg axis; the
# band/cuff fractions here are jogger parameters, not denim's)
# --------------------------------------------------------------------------
def finalize_jogger_landmarks(lm, waist_plane, ankle_plane,
band_frac, cuff_frac):
lm.waist_z = waist_plane
lm.hem_z = ankle_plane
lm.span = waist_plane - ankle_plane
lm.sh = lm.hip_x / denim._REF_HIP_X
lm.band_h = band_frac * lm.span
lm.cuff_h = cuff_frac * lm.span
lm.seam_w = SEAM_W_FRAC * lm.span
lm.band_z = waist_plane - lm.band_h
lm.cuff_top = ankle_plane + lm.cuff_h
log(f"landmarks: waist={lm.waist_z:.3f} hem={lm.hem_z:.3f} "
f"span={lm.span:.3f} hip_x={lm.hip_x:.3f} band_z={lm.band_z:.3f} "
f"cuff_top={lm.cuff_top:.3f} sh={lm.sh:.2f}")
# --------------------------------------------------------------------------
# Tapered offset — the jogger silhouette
# --------------------------------------------------------------------------
def _smoothstep(u):
u = min(max(u, 0.0), 1.0)
return u * u * (3.0 - 2.0 * u)
def tapered_offset(shell, knee_z, cuff_top, off_hi, off_mid, off_cuff,
step_w):
"""Per-vertex outward offset along smoothed normals: `off_hi` above the
knee, smoothstep taper to `off_mid` at the cuff top, then a feathered step
down to the snug `off_cuff` inside the cuff band. The step reads as the
cuff ledge; the A-region tint + painted rib carry the rest."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.normal_update()
denom = max(knee_z - cuff_top, 1e-6)
lo = hi = None
for v in bm.verts:
z = v.co.z
t = off_hi + (off_mid - off_hi) * _smoothstep((knee_z - z) / denom)
s = _smoothstep((cuff_top + step_w - z) / step_w)
t = t + (off_cuff - t) * s
v.co += v.normal * t
lo = t if lo is None else min(lo, t)
hi = t if hi is None else max(hi, t)
bm.to_mesh(me)
bm.free()
me.update()
log(f"tapered offset: {hi * 1000:.1f} mm (thigh) -> {lo * 1000:.1f} mm "
f"(cuff); knee_z={knee_z:.3f} cuff_top={cuff_top:.3f}")
# --------------------------------------------------------------------------
# Jogger feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _jogger_field(px, py, pz, lm):
"""Evaluate jogger features at texel 3D positions (numpy arrays).
Returns (front, in_band, in_cuff, stripe, arc): `stripe` feeds the mask B
channel, `in_band`/`in_cuff` feed R/A, `arc` is the azimuth arc-length
coordinate around the per-z leg axis used by the rib paint.
"""
front = 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 (denim.LegLandmarks control points).
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
# Side stripe: azimuth toward the outer (+/-x) direction; constant metric
# width via arc distance (same construction as the denim outseam).
arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
stripe = mid & (arc_out < 0.5 * STRIPE_W_M * lm.sh)
# Rib coordinate: signed azimuth arc length (0 at the outseam; the +/-pi
# wrap sits at the inner ankle / body centre where it cannot be seen).
arc = np.arctan2(dy, dx * side) * r
return front, in_band, in_cuff, stripe, arc
def _paint_texels(px, py, pz, noise, lm):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
n = px.shape[0]
front, in_band, in_cuff, stripe, arc = _jogger_field(px, py, pz, lm)
# --- albedo ------------------------------------------------------------
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = FLEECE_RGB[c] + noise
alb[:, 3] = 1.0
# Side stripe: bright, low-noise (crisp edge carries the sport read).
for c in range(3):
alb[stripe, c] = STRIPE_RGB[c] + 0.5 * noise[stripe]
if not PLAIN:
# Cuff knit rib: alternating shade stripes, constant metric width.
rib_dark = (np.floor(arc / (RIB_PERIOD_M * lm.sh)).astype(np.int64)
% 2) == 0
cuff_f = np.where(rib_dark, RIB_LO, 1.0) * CUFF_SHADE
alb[in_cuff, :3] *= cuff_f[in_cuff, None]
# Waistband elastic rib: same construction, subtler.
band_dark = (np.floor(arc / (BAND_RIB_PERIOD_M * lm.sh))
.astype(np.int64) % 2) == 0
band_f = np.where(band_dark, BAND_RIB_LO, 1.0) * BAND_SHADE
alb[in_band, :3] *= band_f[in_band, None]
# Border stitching: darker lines under the band and above the cuff.
w2 = lm.seam_w * 0.5
stitch = (np.abs(pz - lm.band_z) < w2) | (np.abs(pz - lm.cuff_top) < w2)
alb[stitch, :3] *= STITCH_SHADE
# Drawstring: two eyelets on the band + two slanted hanging cords.
ex = CORD_X_M * lm.sh
eyelet_z = lm.band_z + 0.35 * lm.band_h
eyelets = front & (
np.hypot(np.abs(px) - ex, pz - eyelet_z) < EYELET_R_M * lm.sh)
drop = CORD_DROP_FRAC * lm.span
hang = np.clip(eyelet_z - pz, 0.0, None)
cord_cx = ex + CORD_SLANT * hang
cords = front & (pz > eyelet_z - drop) & (pz <= eyelet_z) \
& (np.abs(np.abs(px) - cord_cx) < 0.5 * CORD_W_M * lm.sh)
for c in range(3):
alb[eyelets | cords, c] = CORD_RGB[c]
else:
alb[in_cuff, :3] *= CUFF_SHADE
alb[in_band, :3] *= BAND_SHADE
# --- region mask: band R / legs G / stripe B / cuffs A -------------------
mask = np.zeros((n, 4), dtype=np.float32)
is_b = stripe & ~in_band & ~in_cuff
is_g = ~(in_band | in_cuff | is_b)
mask[in_band, 0] = 1.0
mask[is_g, 1] = 1.0
mask[is_b, 2] = 1.0
mask[in_cuff, 3] = 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 jogger field, write albedo + mask together (same rasterizer
construction as the denim companion; the paint field is what differs)."""
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] = FLEECE_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"jogger_albedo_{body}", albedo_path)
_save(mask_buf, f"jogger_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
return albedo_img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_jogger_shell(body_dir, out_dir, body):
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
denim.weld_boundaries(shell)
zs = [v.co.z for v in shell.data.vertices]
waist_z, ankle_z = max(zs), min(zs)
lm = denim.LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
# Full-length hem AT the ankle joint (calf tail); rim teeth flattened onto
# clean planes pre-offset (denim practice — no jagged silhouette).
ankle_plane = float(lm.leg_z_pts[0])
waist_plane, ankle_plane = denim.flatten_open_rims(shell, ankle_plane)
span = waist_plane - ankle_plane
knee_z = float(lm.leg_z_pts[1]) # calf head
cuff_top = ankle_plane + CUFF_FRAC * span
tapered_offset(shell, knee_z, cuff_top, OFFSET_HI_M, OFFSET_MID_M,
OFFSET_CUFF_M, CUFF_STEP_W_M)
denim.waist_flare(shell, waist_plane, BAND_FRAC * span, WAIST_FLARE_M)
base.solidify(shell, base.CLOTH_THICKNESS_M)
denim.clamp_waist_residue(shell, waist_plane)
# Landmarks reference the CLEAN rims for the paint/mask pass.
finalize_jogger_landmarks(lm, waist_plane, ankle_plane, BAND_FRAC,
CUFF_FRAC)
denim.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 OFFSET_HI_M, OFFSET_MID_M, OFFSET_CUFF_M, BAND_FRAC, CUFF_FRAC
global STRIPE_W_M, FLEECE_RGB, STRIPE_RGB, WAIST_FLARE_M, NOISE_SEED, PLAIN
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] [--mid-offset M] [--cuff-offset M] "
"[--band-frac F] [--cuff-frac F] [--stripe-width M] "
"[--fabric r,g,b] [--stripe-rgb r,g,b] [--waist-flare M] "
"[--seed N] [--plain]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
if "--offset" in argv:
OFFSET_HI_M = float(argv[argv.index("--offset") + 1])
if "--mid-offset" in argv:
OFFSET_MID_M = float(argv[argv.index("--mid-offset") + 1])
if "--cuff-offset" in argv:
OFFSET_CUFF_M = float(argv[argv.index("--cuff-offset") + 1])
if "--band-frac" in argv:
BAND_FRAC = float(argv[argv.index("--band-frac") + 1])
if "--cuff-frac" in argv:
CUFF_FRAC = float(argv[argv.index("--cuff-frac") + 1])
if "--stripe-width" in argv:
STRIPE_W_M = float(argv[argv.index("--stripe-width") + 1])
if "--fabric" in argv:
FLEECE_RGB = tuple(
float(v) for v in argv[argv.index("--fabric") + 1].split(","))
if "--stripe-rgb" in argv:
STRIPE_RGB = tuple(
float(v) for v in argv[argv.index("--stripe-rgb") + 1].split(","))
if "--waist-flare" in argv:
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
if "--seed" in argv:
NOISE_SEED = int(argv[argv.index("--seed") + 1])
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"joggers per-body mode: {len(bodies)} bodies, offset "
f"{OFFSET_HI_M * 1000:.0f}->{OFFSET_CUFF_M * 1000:.1f} mm, "
f"band_frac={BAND_FRAC} cuff_frac={CUFF_FRAC} "
f"stripe={STRIPE_W_M * 1000:.0f} mm, 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_jogger_shell(body_dir, out_dir, body)
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()
+726
View File
@@ -0,0 +1,726 @@
"""
blender_author_parka.py (T-1089 wave 2 — parka_thrds, hip-length insulated parka)
Cold-weather parka authored as per-body offset shells — the FIRST CANON-BRANDED
garment (thrds, the Braemar fiber cooperative, wiki/star-systems/GJ-475).
Companion to blender_author_offset_shell.py (imported as a library): reuses the
base module's segment join / bone-ratio thresholds / offset / solidify /
logo-UV2 / export machinery and layers the parka geometry + texture identity on
top, combining the proven practices of the wave-1 family:
* HIP-LENGTH — covers torso + torso_upper + FULL arms + the TOP of the hips
zone: seg_hips joins the shell, the coincident torso|hips overlap band is
WELDED before offsetting (denim practice — un-welded rings offset along
diverging normals and open cracks), and the hem is cut at a bone-derived
plane between the pelvis head and spine_01 head (no skirt panel below).
The vert-threshold hem cut leaves jagged teeth, so the open hem rim is
FLATTENED onto its own valley plane (denim flatten practice) for a clean
straight hem, which Solidify(use_rim) then caps.
* HOOD-DOWN ROLL — the hoodie's rolled-collar inflation with bulkier parka
parameters (radial bulge away from the neck axis, back-biased, rim lift).
* CHUNKY INSULATED READ — painted, not modelled: horizontal quilt channel
lines (constant-z on the body, constant-|x| around the sleeves) with a
soft per-channel "puff" luminance gradient; big front patch pockets with
flaps; a storm-flap front placket with bright zip-teeth dashes; hem
drawcord band; sleeve cuff bands. All identity is carried as LUMINANCE so
the luma-preserving toon_garment recolor keeps it under any tint.
* REGIONS (spec): R = hood roll + collar, G = body, B = sleeves,
A = pocket / quilt trim (quilt lines, pockets, storm flap, cuff bands).
Albedo and mask come from ONE shared per-texel feature-field evaluation
(denim practice) so they always agree. The mask ships CHANNEL_PACKED with
an alpha floor (coverall lesson: Godot's fix_alpha_border pass + Blender's
straight-alpha PNG save both destroy a plain A channel).
* LOGO-CAPABLE — left-breast chest patch (the storm flap owns the centre
line), same bone-ratio box as jacket_modern; painted as a brighter patch
rectangle kept in the G region so the decal reads on the body tint.
Per-body only (Q-060: offset shells author per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_parka.py -- \
client/assets/characters/bodies client/assets/characters/clothing/parka_thrds \
[--bodies a,b,c] [--offset 0.026] [--hem-frac 0.15]
Writes per body: <out_dir>/<body>.glb (skinned, painted albedo embedded)
<out_dir>/<body>_mask.png (RGBA region mask, that body's 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 importlib.util
import os
import shutil
import sys
import bmesh
import bpy
import numpy as np
from mathutils import Vector
# --------------------------------------------------------------------------
# Load the base offset-shell module (shared engine 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)
def log(msg):
print(f"[parka] {msg}")
# --------------------------------------------------------------------------
# PARAMS — parka_thrds. A parka-family variant should only touch this block.
# --------------------------------------------------------------------------
GARMENT_ID = "parka_thrds"
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.026 # chunkiest standoff of the tops family — an insulated
# parka worn over layers (jacket_modern is 0.022)
THICKNESS_M = 0.008 # insulated cloth (jacket 5 mm, hoodie 6 mm)
WELD_DIST = 5e-4 # coincident segment-seam weld tolerance (denim)
# Hem: fraction of the pelvis->spine_01 span ABOVE the pelvis head. 0.15 lands
# just below the hip joint (thigh head) on every body — hip-length, no skirt.
HEM_PELVIS_FRAC = 0.15
HEM_ALLOW_FRAC = 0.50 # hem-ring search band above the cut plane (same span)
# Sleeves: wrist cut + cuff band (jacket/hoodie practice — cuff anchored to the
# ACTUAL post-cut mesh reach, the jagged cut stops 1-3 cm short of the plane).
CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept
CUFF_LEN_FRAC = 0.14 # cuff band length as fraction of the lowerarm
# Collar / hood-down roll (fractions of neck_01 length unless noted).
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below the neck head
COLLAR_X_MULT = 1.20 # parka collar wider than the tee band
ROLL_OUT_FRAC = 0.50 # radial bulge magnitude (hoodie 0.40 — bulkier)
ROLL_LIFT_FRAC = 0.16 # upward lift at the rim
ROLL_Z_START_FRAC = 0.50 # roll influence starts this far below collar_z_min
ROLL_REACH_COLLAR_X = 1.80 # candidate radius around neck axis (x tee collar_x)
ROLL_FRONT_GAIN = 0.60 # bulge scale at the front...
ROLL_BACK_GAIN = 1.25 # ...and the back (hood mass hangs behind)
ROLL_EXPONENT = 1.80 # falloff sharpness toward the rim
R_Z_DROP_FRONT = 0.20 # R region drop below collar_z_min (x neck_len)
R_Z_DROP_BACK = 0.60 # asymmetric — the roll drapes lower on the back
# Storm flap + painted zip (metric refs scale by shoulder |x| / 0.1919).
FLAP_HALF_FRAC = 0.026 / 0.1919 # storm-flap half-width, frac of shoulder |x|
TEETH_HALF_M_REF = 0.0065 # zip teeth strip half-width
TEETH_PERIOD_M_REF = 0.026 # dash period along Z
TEETH_DUTY_M_REF = 0.015 # bright dash length within a period
# Big front patch pockets (x fracs of shoulder |x|, z fracs of garment span).
POCKET_CX_FRAC = 0.44
POCKET_HW_FRAC = 0.25
POCKET_Z0_FRAC = 0.06 # clear of the hem drawcord band (HEM_BAND_FRAC 0.045)
POCKET_Z1_FRAC = 0.255
POCKET_FLAP_FRAC = 0.30 # top fraction of the pocket = flap
# Quilt channels (fracs of the garment span = collar_z_min - hem).
QUILT_PERIOD_FRAC = 0.13
QUILT_HALFW_FRAC = 0.0070 # painted line half-width
HEM_BAND_FRAC = 0.045 # drawcord hem band height
PUFF_AMP = 0.045 # per-channel puff luminance amplitude
# Left-breast logo patch (character-left = +X; the flap owns the centre line).
# Same average_m absolutes as jacket_modern (0.035..0.115 m, z 1.30..1.42 m).
CHEST_X_LO_FRAC = 0.035 / 0.1919
CHEST_X_HI_FRAC = 0.115 / 0.1919
CHEST_Z_LO_FRAC = (1.30 - 1.072) / (1.5205 - 1.072)
CHEST_Z_HI_FRAC = (1.42 - 1.072) / (1.5205 - 1.072)
# Painted luma palette (toon_garment recolors by luma: tint * (luma*1.5+0.2)).
BASE_LUMA = 0.55
QUILT_LUMA = 0.40 # quilt channel lines
ROLL_LUMA = 0.62 # hood roll reads lighter/lofted
FLAP_LUMA = 0.50 # storm flap band
POCKET_LUMA = 0.60 # pocket patch fill
POCKET_FLAP_LUMA = 0.47 # pocket flap
PATCH_LUMA = 0.68 # chest logo patch backing
CUFF_LUMA = 0.46 # sleeve cuff bands
HEM_LUMA = 0.48 # hem drawcord band
STITCH_LUMA = 0.33 # stitch / outline lines
TEETH_LUMA = 0.88 # bright zip teeth dashes
TEETH_GAP_LUMA = 0.30 # dark zip tape between dashes
ALBEDO_NOISE = 0.02 # woven jitter
FRONT_EPS_M = 0.004 # front-facing gate on |y| (denim practice)
# Deep blue-grey cold-weather hue (visible when the region mask is absent;
# with the mask bound, identity is carried by luma alone).
FABRIC_HUE = np.array([0.62, 0.70, 0.88], dtype=np.float32)
TEX_SIZE = 1024 # albedo + mask (painted quilt lines need > 512)
NOISE_SEED = 2093
_REF_SHOULDER_X = 0.1919 # average_m anchor (same as base/jacket)
# Region labels -> mask channels (spec: roll+collar=R, body=G, sleeves=B, trim=A).
LBL_G, LBL_R, LBL_B, LBL_A = 0, 1, 2, 3
LABEL_NAMES = ["body", "roll", "sleeve", "trim"]
LABEL_RGBA_ARR = np.array([
(0.0, 1.0, 0.0, 0.0), # G body
(1.0, 0.0, 0.0, 0.0), # R hood roll + collar
(0.0, 0.0, 1.0, 0.0), # B sleeves
(0.0, 0.0, 0.0, 1.0), # A pocket / quilt trim
], dtype=np.float32)
# --------------------------------------------------------------------------
# Thresholds — base derivation + parka extras from the same armature
# --------------------------------------------------------------------------
def parka_thresholds(armature):
thr = base.derive_thresholds(armature)
bones = armature.data.bones
def bone(name):
b = bones.get(name)
if b is None:
raise RuntimeError(f"landmark bone {name} missing")
return b
ua_l = bone("upperarm_l")
ua_r = bone("upperarm_r")
neck = bone("neck_01")
pelvis = bone("pelvis")
spine01 = bone("spine_01")
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
scale = shoulder_x / _REF_SHOULDER_X
# Collar band (taller + wider than the tee) and hood-roll geometry.
neck_len = neck.tail_local.z - neck.head_local.z
tee_collar_x = thr["collar_x_abs"] # before the parka widening
thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
thr["collar_x_abs"] = tee_collar_x * COLLAR_X_MULT
thr["neck_y"] = neck.head_local.y
thr["neck_len"] = neck_len
thr["roll_out"] = ROLL_OUT_FRAC * neck_len
thr["roll_lift"] = ROLL_LIFT_FRAC * neck_len
thr["roll_reach"] = ROLL_REACH_COLLAR_X * tee_collar_x
# Hip-length hem plane + ring-search allowance (bone-derived, per body).
pz, sz = pelvis.head_local.z, spine01.head_local.z
thr["hem_z"] = pz + HEM_PELVIS_FRAC * (sz - pz)
thr["hem_allow"] = HEM_ALLOW_FRAC * (sz - pz)
# Wrist cut planes on the lowerarm bones (jacket practice).
cut_planes = []
lowerarm_len = 0.0
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
b = bone(bone_name)
head_x, tail_x = b.head_local.x, b.tail_local.x
cut_planes.append((sign, head_x + CUFF_KEEP_FRAC * (tail_x - head_x)))
lowerarm_len = abs(tail_x - head_x)
thr["wrist_cut_planes"] = cut_planes
thr["cuff_len"] = CUFF_LEN_FRAC * lowerarm_len
# Storm flap + zip teeth.
thr["flap_half"] = shoulder_x * FLAP_HALF_FRAC
thr["teeth_half"] = TEETH_HALF_M_REF * scale
thr["teeth_period"] = TEETH_PERIOD_M_REF * scale
thr["teeth_duty"] = TEETH_DUTY_M_REF * scale
# Pockets (x now; z after the span is known in finalize_thresholds).
thr["pocket_cx"] = POCKET_CX_FRAC * shoulder_x
thr["pocket_hw"] = POCKET_HW_FRAC * shoulder_x
# Left-breast logo patch (replaces the base full-chest box).
thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC)
spine_lo = spine01.head_local.z
span = neck.head_local.z - spine_lo
thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span,
spine_lo + CHEST_Z_HI_FRAC * span)
log(f"thresholds: hem z={thr['hem_z']:.3f} collar z>={thr['collar_z_min']:.3f} "
f"|x|<{thr['collar_x_abs']:.3f} roll out={thr['roll_out']*1000:.0f}mm "
f"reach={thr['roll_reach']:.3f} flap half={thr['flap_half']:.3f} "
f"pocket cx={thr['pocket_cx']:.3f} hw={thr['pocket_hw']:.3f} "
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
return thr
def finalize_thresholds(shell, thr):
"""Mesh-measured extras after cuts/flatten/offset/roll: the garment span
(drives quilt/pocket/hem-band proportions) and the cuff anchor from the
sleeves' ACTUAL post-cut reach (hoodie practice)."""
span = thr["collar_z_min"] - thr["hem_z"]
thr["span"] = span
thr["quilt_period"] = QUILT_PERIOD_FRAC * span
thr["quilt_halfw"] = QUILT_HALFW_FRAC * span
thr["hem_band_h"] = HEM_BAND_FRAC * span
thr["pocket_z0"] = thr["hem_z"] + POCKET_Z0_FRAC * span
thr["pocket_z1"] = thr["hem_z"] + POCKET_Z1_FRAC * span
thr["pocket_flap_h"] = POCKET_FLAP_FRAC * (thr["pocket_z1"] - thr["pocket_z0"])
wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=0.0)
thr["cuff_x0"] = wrist_x - thr["cuff_len"]
log(f"finalized: span={span:.3f} quilt period={thr['quilt_period']*1000:.0f}mm "
f"pocket z=({thr['pocket_z0']:.3f},{thr['pocket_z1']:.3f}) "
f"cuff |x|>={thr['cuff_x0']:.3f} (reach {wrist_x:.3f})")
# --------------------------------------------------------------------------
# Geometry: weld, cuts, hem flatten, hood roll
# --------------------------------------------------------------------------
def weld_boundaries(shell):
"""Merge coincident segment-boundary verts (denim practice) so the offset
can't open cracks at the torso|hips / torso|arm seams. Weights are
identical by origin, so skinning is unaffected."""
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 wrist_cut(shell, thr):
"""Delete forearm verts beyond the wrist plane on each side (jacket)."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = []
for v in bm.verts:
for sign, thr_x in thr["wrist_cut_planes"]:
if sign > 0 and v.co.x > thr_x:
to_delete.append(v)
break
if sign < 0 and v.co.x < thr_x:
to_delete.append(v)
break
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"wrist cut removed {len(to_delete)} verts; "
f"{len(shell.data.vertices)} remain")
def hem_cut(shell, thr):
"""Trim everything below the hip-length hem plane (removes the seg_hips
lower boundary, crotch and leg openings entirely)."""
hem_z = thr["hem_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")
def flatten_hem_rim(shell, thr):
"""Pull the open hem-ring verts onto the ring's own valley plane (denim
flatten practice) — the vert-threshold cut leaves jagged teeth; a clean
straight hem is what Solidify(use_rim) then caps. Only boundary verts in
the hem band move; the neck and wrist rims sit far above `hem_allow`."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
limit = thr["hem_z"] + thr["hem_allow"]
ring = set()
for e in bm.edges:
if len(e.link_faces) == 1: # open boundary
for v in e.verts:
if v.co.z < limit:
ring.add(v.index)
if not ring:
log("WARNING: no hem-ring verts found — hem left jagged")
bm.free()
return
zs = [bm.verts[i].co.z for i in ring]
valley = min(zs)
teeth = max(zs) - valley
for i in ring:
bm.verts[i].co.z = valley
bm.to_mesh(me)
bm.free()
me.update()
thr["hem_z"] = valley # paint anchors reference the clean rim
log(f"flattened hem rim: {len(ring)} verts, teeth {teeth:.3f} m "
f"-> plane z={valley:.3f}")
def hood_roll(shell, thr):
"""Inflate collar-band verts radially away from the neck axis to read as a
rolled-down hood (hoodie practice, parka-bulk parameters). Runs after the
outward offset and before solidify so the roll gets cloth thickness."""
import math
z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * thr["neck_len"]
reach = thr["roll_reach"]
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
candidates = []
z_rim = z_start
for v in bm.verts:
if v.co.z < z_start:
continue
hd = math.hypot(v.co.x, v.co.y - thr["neck_y"])
if hd > reach or hd < 1e-6:
continue
candidates.append((v, hd))
z_rim = max(z_rim, v.co.z)
if z_rim <= z_start or not candidates:
log("WARNING: no hood-roll candidates found — roll skipped")
bm.free()
return
moved = 0
for v, hd in candidates:
t = (v.co.z - z_start) / (z_rim - z_start)
w = max(0.0, min(1.0, t)) ** ROLL_EXPONENT
if w <= 0.0:
continue
dir_h = Vector((v.co.x, v.co.y - thr["neck_y"], 0.0)) / hd
backness = 0.5 * (1.0 + dir_h.y * -base.FRONT_Y_SIGN) # +Y = back
gain = ROLL_FRONT_GAIN + (ROLL_BACK_GAIN - ROLL_FRONT_GAIN) * backness
v.co += dir_h * (thr["roll_out"] * w * gain)
v.co.z += thr["roll_lift"] * w
moved += 1
bm.to_mesh(me)
bm.free()
me.update()
log(f"hood roll: {moved} verts inflated (rim z {z_rim:.3f}, "
f"start z {z_start:.3f})")
# --------------------------------------------------------------------------
# Feature field — ONE evaluation drives albedo luma AND region label together
# --------------------------------------------------------------------------
def parka_field(px, py, pz, thr):
"""Evaluate the parka feature field at texel 3D positions (numpy arrays).
Returns (label uint8 array indexing LABEL_RGBA_ARR, luma float32 array).
"""
n = px.shape[0]
label = np.full(n, LBL_G, dtype=np.uint8)
luma = np.full(n, BASE_LUMA, dtype=np.float32)
ax = np.abs(px)
front = py * base.FRONT_Y_SIGN > FRONT_EPS_M
ew = thr["quilt_halfw"] # shared edge/stitch line half-width
on_sleeve = ax >= thr["sleeve_x_abs"]
label[on_sleeve] = LBL_B
# --- zones -------------------------------------------------------------
hem = thr["hem_z"]
hem_top = hem + thr["hem_band_h"]
hem_band = pz <= hem_top
cuff = ax >= thr["cuff_x0"]
flap = front & (ax <= thr["flap_half"])
pocket_dx = np.abs(ax - thr["pocket_cx"])
in_pocket_z = (pz >= thr["pocket_z0"]) & (pz <= thr["pocket_z1"])
pocket = front & in_pocket_z & (pocket_dx <= thr["pocket_hw"])
m = ew * 3.0 # quilt keep-out margin around painted features
pocket_pad = front & (pz >= thr["pocket_z0"] - m) & (pz <= thr["pocket_z1"] + m) \
& (pocket_dx <= thr["pocket_hw"] + m)
x0, x1 = thr["chest_x"]
z0, z1 = thr["chest_z"]
patch = front & (px >= x0) & (px <= x1) & (pz >= z0) & (pz <= z1)
patch_pad = front & (px >= x0 - m) & (px <= x1 + m) \
& (pz >= z0 - m) & (pz <= z1 + m)
hd = np.hypot(px, py - thr["neck_y"])
roll_zmin = thr["collar_z_min"] \
- np.where(front, R_Z_DROP_FRONT, R_Z_DROP_BACK) * thr["neck_len"]
# Sleeve texels never join the roll (hoodie precedence: sleeve check first)
# — without the gate the deltoid caps classify as R on every body.
roll = (pz >= roll_zmin) & ~on_sleeve \
& (hd <= thr["roll_reach"] + thr["roll_out"] + 0.01)
# --- quilt channels (constant-z on the body, constant-|x| on sleeves) ---
coord = np.where(on_sleeve, ax - thr["sleeve_x_abs"], pz - hem)
period = thr["quilt_period"]
tph = np.mod(coord, period) / period
edge_d = np.minimum(tph, 1.0 - tph) * period
quilt_ok = ~(hem_band | cuff | flap | pocket_pad | patch_pad | roll)
qline = quilt_ok & (edge_d <= ew)
puff = 4.0 * tph * (1.0 - tph)
# --- luma (paint order = precedence, later writes win) ------------------
luma += np.where(quilt_ok, PUFF_AMP * (puff - 0.5) * 2.0, 0.0)
luma[qline] = QUILT_LUMA
luma[hem_band] = HEM_LUMA
hemline = (np.abs(pz - hem_top) <= ew) & ~on_sleeve
luma[hemline] = STITCH_LUMA
luma[cuff] = CUFF_LUMA
cuffline = np.abs(ax - thr["cuff_x0"]) <= ew
luma[cuffline] = STITCH_LUMA
# Big patch pockets: fill, flap, stitch outline.
luma[pocket] = POCKET_LUMA
flap_z = thr["pocket_z1"] - thr["pocket_flap_h"]
luma[pocket & (pz >= flap_z)] = POCKET_FLAP_LUMA
p_outline = pocket & (
(pocket_dx >= thr["pocket_hw"] - ew)
| (pz <= thr["pocket_z0"] + ew)
| (pz >= thr["pocket_z1"] - ew)
| (np.abs(pz - flap_z) <= ew)
)
luma[p_outline] = STITCH_LUMA
# Chest logo patch backing (stays in G so the decal reads on body tint).
luma[patch] = PATCH_LUMA
patch_edge = patch & (
(px <= x0 + ew) | (px >= x1 - ew) | (pz <= z0 + ew) | (pz >= z1 - ew)
)
luma[patch_edge] = STITCH_LUMA
# Storm flap + zip teeth (teeth stop under the hood roll).
luma[flap] = FLAP_LUMA
flap_edge = front & (np.abs(ax - thr["flap_half"]) <= ew * 0.8)
luma[flap_edge] = STITCH_LUMA
teeth = front & (ax <= thr["teeth_half"]) & ~roll
dash = np.mod(pz, thr["teeth_period"]) < thr["teeth_duty"]
luma[teeth & dash] = TEETH_LUMA
luma[teeth & ~dash] = TEETH_GAP_LUMA
# Hood roll last: lofted read + a collar seam line right under it.
rollseam = (np.abs(pz - roll_zmin) <= ew) & ~on_sleeve \
& (hd <= thr["roll_reach"] + thr["roll_out"] + 0.02)
luma[rollseam & ~roll] = STITCH_LUMA
luma[roll] = ROLL_LUMA
# --- region labels (same masks; precedence: trim, then roll wins) -------
label[qline] = LBL_A
label[cuff] = LBL_A
label[pocket] = LBL_A
label[flap] = LBL_A
label[roll] = LBL_R
return label, luma
# --------------------------------------------------------------------------
# Combined albedo + mask bake (per-texel, pre-solidify — coverall practice)
# --------------------------------------------------------------------------
def _tri_texels(a, b, c, W, H):
"""Texels covered by UV triangle (a,b,c) with barycentric weights
(coverall rasterizer: returns ys, xs, w0, w1, w2)."""
empty = (np.empty(0, int),) * 2 + (np.empty(0, np.float32),) * 3
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 empty
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
if abs(denom) < 1e-9:
return empty
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 empty
return (
ys[inside], xs[inside],
w0[inside].astype(np.float32),
w1[inside].astype(np.float32),
w2[inside].astype(np.float32),
)
def bake_albedo_and_mask(shell, thr, out_dir, body, seed):
"""One pass over the UV0 faces: albedo and mask from a single shared
feature-field evaluation per texel (denim practice). Baked PRE-solidify so
there is exactly one face per texel (coverall practice)."""
W = H = TEX_SIZE
rng = np.random.default_rng(seed)
noise_buf = (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
alb = np.empty((H, W, 4), dtype=np.float32)
alb[:, :, 0:3] = np.clip(
(BASE_LUMA + noise_buf)[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0)
alb[:, :, 3] = 1.0
mask = np.zeros((H, W, 4), dtype=np.float32)
mask[:, :, 1] = 1.0 # body-green background (bilinear-bleed safe)
label_map = np.full((H, W), LBL_G, dtype=np.uint8)
covered = np.zeros((H, W), dtype=bool)
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 bake")
uv_layer = bm.loops.layers.uv[0]
tri_count = 0
for face in bm.faces:
loops = face.loops[:]
uvs = [loop[uv_layer].uv for loop in loops]
pos = [loop.vert.co for loop in loops]
for i in range(1, len(loops) - 1):
tri = (0, i, i + 1)
ys, xs, w0, w1, w2 = _tri_texels(
uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H)
if ys.size == 0:
continue
px3 = w0 * pos[tri[0]].x + w1 * pos[tri[1]].x + w2 * pos[tri[2]].x
py3 = w0 * pos[tri[0]].y + w1 * pos[tri[1]].y + w2 * pos[tri[2]].y
pz3 = w0 * pos[tri[0]].z + w1 * pos[tri[1]].z + w2 * pos[tri[2]].z
lab, lum = parka_field(px3, py3, pz3, thr)
lum = np.clip(lum + noise_buf[ys, xs], 0.0, 1.0)
alb[ys, xs, 0:3] = np.clip(
lum[:, None] * FABRIC_HUE[None, :], 0.0, 1.0)
mask[ys, xs] = LABEL_RGBA_ARR[lab]
label_map[ys, xs] = lab
covered[ys, xs] = True
tri_count += 1
bm.free()
total = max(int(covered.sum()), 1)
counts = np.bincount(label_map[covered], minlength=len(LABEL_NAMES))
log(f"painted {tri_count} UV triangles; region texels: " + " ".join(
f"{LABEL_NAMES[i]}={int(c)} ({100.0 * c / total:.1f}%)"
for i, c in enumerate(counts)))
# Alpha floor (coverall lesson): Godot's fix_alpha_border import pass
# rewrites RGB of fully-transparent texels — floor A at 2/255 everywhere.
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
mask_path = os.path.join(out_dir, f"{body}_mask.png")
img_mask = bpy.data.images.new(f"{GARMENT_ID}_mask_{body}", W, H, alpha=True)
# Channel-packed DATA, not imagery: a straight-alpha PNG save would zero
# the A (trim) channel (coverall lesson, verified there).
img_mask.alpha_mode = 'CHANNEL_PACKED'
img_mask.pixels.foreach_set(mask.reshape(-1))
img_mask.update()
img_mask.filepath_raw = mask_path
img_mask.file_format = 'PNG'
img_mask.save()
log(f"baked region mask -> {mask_path}")
img_alb = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False)
img_alb.pixels.foreach_set(alb.reshape(-1))
img_alb.update()
return img_alb
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_parka(body_dir, out_dir, body, offset, seed):
base.clear_scene()
base.COVERED_SEGMENTS = SEGMENTS # build_covered_mesh reads this
shell, armature = base.build_covered_mesh(body_dir)
weld_boundaries(shell)
thr = parka_thresholds(armature)
wrist_cut(shell, thr)
hem_cut(shell, thr)
flatten_hem_rim(shell, thr)
base.offset_outward(shell, offset)
hood_roll(shell, thr)
finalize_thresholds(shell, thr)
# UV2 + bake BEFORE solidify (coverall practice: one face per texel).
base.author_logo_uv(shell, thr)
albedo_img = bake_albedo_and_mask(shell, thr, out_dir, body, seed)
base.solidify(shell, THICKNESS_M)
base.assign_fabric_material(shell, albedo_img)
# Shared sidecar name before export: the glTF exporter names the embedded
# image after the filepath basename and Godot extracts it as
# <glb>_<image>.png — this lands on the tshirt-convention
# <body>_base_albedo.png (coverall/jacket practice).
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
os.path.join(out_dir, f"{body}_base_albedo.png"))
def main():
global HEM_PELVIS_FRAC
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]")
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-frac" in argv:
HEM_PELVIS_FRAC = float(argv[argv.index("--hem-frac") + 1])
bodies = base.BODY_TYPES
if "--bodies" in argv:
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
os.makedirs(out_dir, exist_ok=True)
log(f"parka per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
f"hem-frac {HEM_PELVIS_FRAC}")
results = []
for i, body in enumerate(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_parka(body_dir, out_dir, body, offset, seed=NOISE_SEED + i)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
# Deterministic shared sidecars mirror the reference body (average_m).
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()
@@ -0,0 +1,755 @@
"""
blender_author_shoes_formal.py (T-1089 wave 2, footwear: shoes_formal)
Authors FORMAL SHOES (both feet, one garment — the peasant_shoes convention)
as per-body offset shells, reusing blender_author_offset_shell.py as a library
(scene build, join, offset, solidify, GLB export) and the denim companion's
required bottoms/footwear practices (boundary WELD, parked logo UV2).
What footwear adds, as reusable parameters (sneakers/boots can re-drive this):
* both-feet build: seg_foot_l + seg_foot_r joined into ONE skinned mesh;
the two shells stay disjoint (weights by construction, one draw call).
* boundary WELD is load-bearing here, not just hygienic: a raw seg_foot is
3-8 mesh islands (upper + a separate flat SOLE PLATE sheet + ankle-band
slivers). remove_doubles(0.5 mm) zips them into one shell per foot whose
only open boundary is the ankle ring (verified on average_m + child).
* ankle TOPLINE cut: the welded ankle ring is jagged weight-threshold teeth
(~5 cm on average_m). Verts above the ring's own valley (optionally
+--topline-lift) are deleted and the fresh boundary is flattened UP onto
the topline plane — a clean horizontal low-profile shoe opening. Guard
rails clamp the topline to [0.70, 0.95] x the foot-bone head height.
The rim ring is then RELAXED in XY (neighbour averaging, z pinned) so the
throat reads as a smooth loafer opening instead of a jagged U.
* TOE-BOX smoothing: the skin mesh has individual toe bumps; offsetting
them verbatim yields a five-finger foot-glove. Forefoot verts are
Laplacian-smoothed in feathered bands (mild at the metatarsal, strong at
the toes) into one smooth formal toe box, then given a small extra
standoff (--toe-extra) to buy back the clearance smoothing costs over
the toe bumps. Band reps are deliberately MODERATE: smoothing migrates
shell verts away from the skin they are weighted like, and under toe
flex (Walk heel-strike / Sprint push-off) the migration error scales
with sin(flex angle) — first authoring pass used (2,4,8) reps and the
animated toes visibly overtook the toe box.
* CLEARANCE ENFORCEMENT (the fix that guards all of the above): a BVH
snapshot of the post-cut, PRE-smoothing skin surface; after offset +
toe-extra, every shell vert closer than the shell offset to the skin
(signed, along the skin normal) is pushed out to exactly that standoff.
Guaranteed rest-pose clearance by construction, no matter how far the
toe smoothing migrated verts.
* SOLE as an offset-shell param extension: pre-offset, downward-facing
verts (normal.z < -0.5) are recorded; post-offset they are flattened onto
a plane so the finished exterior bottom sits --sole-mm (default 8 mm)
below the skin's lowest point once Solidify adds cloth thickness. A final
clamp keeps every vert on/above the plane — thin flat sole.
* UV0 NORMALIZATION: feet occupy a tiny corner of the body texture atlas;
since BOTH the painted albedo and the region mask are authored here on
UV0 (skin textures are dropped), the used UV bbox is rescaled to fill
[0,1] — ~10x texel density for the painted seams at no cost. Mirrored
L/R UV islands may overlap; all painted features are x-symmetric, so
overlap is harmless by construction.
* texel-level feature painting (denim's albedo+mask-from-one-field idea):
- albedo: painted TOE CAP LINE across the vamp, sole-edge welt stitch,
topline edge stitch, heel counter seam — flat toon-friendly,
identity carried by luminance (the toon_garment shader tints
by luma, so lines survive a black default tint).
- mask: sole -> R, upper -> G, toe cap -> B (spec regions).
* parked logo UV2 (shoes are not logo-capable; shader samples UV2 anyway).
All parameters derive PER BODY from that body's own bone landmarks (foot_l /
ball_l) and measured mesh extents (toe/heel y, skin bottom z, per-foot ankle
ring valley), scaled by foot length against the average_m reference — the
same proportional-ratio philosophy as base.derive_thresholds. Per-body mode
only (offset shells author per body, Q-060).
Usage (shoes_formal reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_shoes_formal.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/shoes_formal \
[--bodies average_m,child,...] [--offset 0.006] [--sole-mm 0.008] \
[--topline-lift 0.0] [--cap-frac 0.35] [--base-rgb r,g,b] \
[--sole-rgb r,g,b] [--plain] [--no-uv-normalize]
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)
Coverage note (the peasant_shoes convention): footwear ships hides: [] — the
foot skin stays VISIBLE at runtime because the shoe throat legitimately shows
the instep (hiding the feet would open a see-through hole there). The
chromakey QA still gates: its config passes an explicit "covers":
["foot_l","foot_r"] so the feet are keyed even though coverage.json hides
nothing.
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 + the denim companion (shared utilities)
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load(mod_name, fname):
spec = importlib.util.spec_from_file_location(
mod_name, os.path.join(_HERE, fname))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
base = _load("offset_shell_base", "blender_author_offset_shell.py")
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
log = base.log
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y; toes point -Y
# --------------------------------------------------------------------------
# Parameters (all reusable across the footwear family)
# --------------------------------------------------------------------------
COVERED_SEGMENTS = ["seg_foot_l", "seg_foot_r"]
SHELL_OFFSET_M = 0.006 # slim low-profile standoff (weights exact per body)
SOLE_TOTAL_M = 0.008 # finished exterior sole drop below the skin bottom
TOPLINE_LIFT_M = 0.0 # extra height above the ankle-ring valley
TOPLINE_FLOOR_FRAC = 0.70 # topline >= this frac of foot-bone head z
TOPLINE_CEIL_FRAC = 0.95 # topline <= this frac of foot-bone head z
CAP_BALL_FRAC = 0.35 # toe-cap line along the ball bone (head -> tail)
SOLE_BAND_M = 0.010 # sole side band height on average_m (R region)
SEAM_W_M = 0.0030 # painted seam width on average_m
SEAM_W_MIN_M = 0.0016 # floor so child seams don't alias away
HEEL_SEAM_FRAC = 0.16 # heel counter seam, fraction of foot len from heel
MIN_ISLAND_VERTS = 10 # post-cut sliver cleanup threshold
TOE_SMOOTH_REPS = (2, 3, 4) # feathered smoothing bands, metatarsal -> toes
# (moderate: heavy reps migrate verts off their
# weight-source skin and animated toes poke out)
TOE_EXTRA_OFFSET_M = 0.005 # extra forefoot standoff after smoothing
RIM_RELAX_PASSES = 2 # XY neighbour-average passes on the topline ring
TEX_SIZE = 1024
UV_NORMALIZE = True # rescale used UV bbox to fill [0,1]
PLAIN = False # --plain: skip painted stitch lines
# Formal leather style (luma carries the detail; runtime tints recolor).
LEATHER_RGB = (0.320, 0.315, 0.330) # upper mid-grey leather
SOLE_RGB = (0.235, 0.230, 0.240) # sole band slightly darker
STITCH_RGB = (0.560, 0.550, 0.570) # painted seam thread (lighter luma)
ALBEDO_NOISE = 0.015
NOISE_SEED = 3089
# Reference proportions (average_m) the fractions were calibrated against.
_REF_FOOT_LEN = 0.2704 # heel y (+0.1374) - toe y (-0.1330)
# --------------------------------------------------------------------------
# Per-body landmarks
# --------------------------------------------------------------------------
class FootLandmarks:
"""Cut/mask/paint parameters derived from one body's bones + mesh."""
def __init__(self, armature, mesh):
bones = armature.data.bones
foot = bones.get("foot_l")
ball = bones.get("ball_l")
if foot is None or ball is None:
raise RuntimeError("foot_l/ball_l missing — not the 65-bone rig?")
self.ankle_z = foot.head_local.z
ys = [v.co.y for v in mesh.vertices]
zs = [v.co.z for v in mesh.vertices]
self.toe_y = min(ys) # toes point -Y (FRONT_Y_SIGN)
self.heel_y = max(ys)
self.skin_min_z = min(zs)
self.foot_len = self.heel_y - self.toe_y
self.s = self.foot_len / _REF_FOOT_LEN
# Toe-cap line sits along the ball bone (metatarsal -> toes).
self.ball_head_y = ball.head_local.y
self.ball_tail_y = ball.tail_local.y
self.cap_y = self.ball_head_y + CAP_BALL_FRAC * (
self.ball_tail_y - self.ball_head_y)
self.heel_seam_y = self.heel_y - HEEL_SEAM_FRAC * self.foot_len
# Geometry planes: Solidify adds CLOTH_THICKNESS_M outward (down at
# the sole), so the pre-solidify flatten plane sits thickness higher.
self.sole_plane = (self.skin_min_z - SOLE_TOTAL_M
+ base.CLOTH_THICKNESS_M)
self.sole_top = self.sole_plane + SOLE_BAND_M * self.s
self.seam_w = max(SEAM_W_M * self.s, SEAM_W_MIN_M)
self.topline = {} # per foot side ('L'/'R'), set by ankle cut
log(f"landmarks: ankle_z={self.ankle_z:.4f} foot_len={self.foot_len:.4f} "
f"(s={self.s:.3f}) cap_y={self.cap_y:.4f} "
f"heel_seam_y={self.heel_seam_y:.4f} skin_min_z={self.skin_min_z:.4f} "
f"sole_plane={self.sole_plane:.4f} sole_top={self.sole_top:.4f} "
f"seam_w={self.seam_w * 1000:.1f}mm")
# --------------------------------------------------------------------------
# Geometry
# --------------------------------------------------------------------------
def recalc_normals(shell):
"""Consistent outward face normals pre-offset (the raw foot carries a
separately-authored sole plate whose orientation is not guaranteed)."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log("recalculated outward face normals")
def _side(x):
return "L" if x >= 0.0 else "R"
def ankle_cut_and_flatten(shell, lm, lift):
"""Per foot: topline = clamp(ankle-ring valley + lift); delete verts above
it; drop disconnected slivers; flatten the fresh boundary UP onto the
plane. Lifting (not lowering) is safe for footwear: the rim sits a full
shell-offset OUTSIDE the skin, so raising it only deepens the overlap
with the (visible) ankle skin — cloth over skin, never a gap."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
# 1. Per-foot ankle-ring valley (the welded shell's only open boundary).
ring_z = {"L": [], "R": []}
for e in bm.edges:
if len(e.link_faces) == 1:
for v in e.verts:
ring_z[_side(v.co.x)].append(v.co.z)
floor_z = TOPLINE_FLOOR_FRAC * lm.ankle_z
ceil_z = TOPLINE_CEIL_FRAC * lm.ankle_z
for side in ("L", "R"):
if not ring_z[side]:
raise RuntimeError(f"no ankle boundary ring on side {side}")
valley = min(ring_z[side])
lm.topline[side] = min(max(valley + lift, floor_z), ceil_z)
log(f"topline {side}: valley={valley:.4f} -> {lm.topline[side]:.4f} "
f"(guards [{floor_z:.4f}, {ceil_z:.4f}], ring teeth "
f"{max(ring_z[side]) - valley:.4f} m)")
# 2. Cut above the topline.
doomed = [v for v in bm.verts if v.co.z > lm.topline[_side(v.co.x)]]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
log(f"ankle cut removed {len(doomed)} verts")
# 3. Sliver cleanup: islands the cut disconnected.
bm.verts.ensure_lookup_table()
seen = set()
doomed_isl = []
for v in bm.verts:
if v.index in seen:
continue
stack, isl = [v], set()
while stack:
cur = stack.pop()
if cur.index in isl:
continue
isl.add(cur.index)
for e in cur.link_edges:
o = e.other_vert(cur)
if o.index not in isl:
stack.append(o)
seen |= isl
if len(isl) < MIN_ISLAND_VERTS:
doomed_isl.extend(isl)
if doomed_isl:
bm.verts.ensure_lookup_table()
bmesh.ops.delete(bm, geom=[bm.verts[i] for i in doomed_isl],
context='VERTS')
log(f"removed {len(doomed_isl)} sliver-island verts")
# 4. Flatten the fresh jagged boundary UP onto the topline plane.
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
lifted = 0
for e in bm.edges:
if len(e.link_faces) == 1:
for v in e.verts:
tl = lm.topline[_side(v.co.x)]
if v.co.z != tl:
v.co.z = tl
lifted += 1
bm.to_mesh(me)
bm.free()
me.update()
log(f"flattened ankle rims: {lifted} boundary verts -> topline planes")
def relax_rim(shell, lm, passes):
"""XY neighbour-averaging over the topline boundary ring (z pinned to the
topline) so the throat opening reads smooth. Mild by design: the ring
sits a full shell-offset outside the skin, and 2 half-weight passes stay
well inside that budget."""
if passes <= 0:
return
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
adj = {}
for e in bm.edges:
if len(e.link_faces) == 1:
a, b = e.verts
adj.setdefault(a.index, []).append(b.index)
adj.setdefault(b.index, []).append(a.index)
for _ in range(passes):
new_xy = {}
for i, nbrs in adj.items():
if not nbrs:
continue
ax = sum(bm.verts[j].co.x for j in nbrs) / len(nbrs)
ay = sum(bm.verts[j].co.y for j in nbrs) / len(nbrs)
v = bm.verts[i]
new_xy[i] = (0.5 * v.co.x + 0.5 * ax, 0.5 * v.co.y + 0.5 * ay)
for i, (x, y) in new_xy.items():
bm.verts[i].co.x = x
bm.verts[i].co.y = y
bm.to_mesh(me)
bm.free()
me.update()
log(f"relaxed topline rim: {len(adj)} verts, {passes} XY passes")
def smooth_toe_box(shell, lm):
"""Feathered Laplacian smoothing over the forefoot so the individual toe
bumps merge into one formal toe box. Bands (mild -> strong toward the
toes) avoid a crease at the smoothing frontier."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
margin = 0.012 * lm.s
bands = [
(lm.ball_head_y + margin, TOE_SMOOTH_REPS[0]),
(lm.ball_head_y, TOE_SMOOTH_REPS[1]),
((lm.ball_head_y + lm.ball_tail_y) * 0.5, TOE_SMOOTH_REPS[2]),
]
total = 0
for y_max, reps in bands:
verts = [v for v in bm.verts if v.co.y < y_max]
if not verts:
continue
for _ in range(reps):
bmesh.ops.smooth_vert(bm, verts=verts, factor=0.5,
use_axis_x=True, use_axis_y=True,
use_axis_z=True)
total += len(verts) * reps
bm.to_mesh(me)
bm.free()
me.update()
log(f"smoothed toe box: bands at y<{[f'{b:.3f}' for b, _ in bands]} "
f"({total} vert-passes)")
def toe_extra_offset(shell, lm, extra):
"""Extra forefoot standoff (feathered along y) to buy back the clearance
the toe smoothing costs over the toe bumps."""
if extra <= 0.0:
return
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.normal_update()
margin = 0.012 * lm.s
y0 = lm.ball_head_y + margin # feather start (0 extra)
y1 = lm.ball_tail_y # full extra from the ball line on
n = 0
for v in bm.verts:
if v.co.y < y0:
t = min((y0 - v.co.y) / max(y0 - y1, 1e-6), 1.0)
v.co += v.normal * (extra * t)
n += 1
bm.to_mesh(me)
bm.free()
me.update()
log(f"toe extra offset: {n} verts, +{extra * 1000:.1f} mm feathered")
def snapshot_skin_bvh(shell):
"""BVH of the current (post-cut, pre-smoothing) skin surface — at this
stage the shell verts still ARE the skin verts, so this is the reference
every later deformation is measured against."""
import mathutils.bvhtree
bm = bmesh.new()
bm.from_mesh(shell.data)
bvh = mathutils.bvhtree.BVHTree.FromBMesh(bm)
bm.free()
log("snapshotted skin surface BVH (clearance reference)")
return bvh
def enforce_clearance(shell, skin_bvh, min_clearance):
"""Push any shell vert closer than `min_clearance` to the skin snapshot
out to exactly that standoff (along the skin normal). This is what makes
the toe smoothing safe: Laplacian migration can leave the toe box inside
the toe bumps; here every vert gets its rest-pose clearance back by
construction."""
me = shell.data
pushed = 0
worst = 0.0
for v in me.vertices:
hit = skin_bvh.find_nearest(v.co)
if hit is None or hit[0] is None:
continue
location, normal, _idx, _dist = hit
signed = (v.co - location).dot(normal)
if signed < min_clearance:
v.co = location + normal * min_clearance
pushed += 1
worst = max(worst, min_clearance - signed)
me.update()
log(f"enforced clearance {min_clearance * 1000:.1f} mm: pushed {pushed} "
f"verts (worst deficit {worst * 1000:.1f} mm)")
def classify_sole_verts(shell):
"""Indices of downward-facing verts (the foot underside), pre-offset."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.normal_update()
idx = [v.index for v in bm.verts if v.normal.z < -0.5]
bm.free()
log(f"classified {idx and len(idx) or 0} sole (downward-normal) verts")
return idx
def flatten_sole(shell, sole_idx, sole_plane):
"""Post-offset: pull the underside onto the flat sole plane, then clamp
everything on/above it (toe rounding can dip below after the offset)."""
me = shell.data
for i in sole_idx:
me.vertices[i].co.z = sole_plane
clamped = 0
for v in me.vertices:
if v.co.z < sole_plane:
v.co.z = sole_plane
clamped += 1
me.update()
log(f"flattened sole: {len(sole_idx)} verts -> z={sole_plane:.4f} "
f"(+{clamped} clamped)")
def clamp_topline_residue(shell, lm):
"""Post-solidify safety clamp: rim-adjacent verts the Solidify pushed
above the topline get squashed back onto it (denim waist pattern)."""
me = shell.data
n = 0
for v in me.vertices:
tl = lm.topline[_side(v.co.x)]
if v.co.z > tl:
v.co.z = tl
n += 1
me.update()
if n:
log(f"clamped {n} residual topline verts")
def normalize_uv0(shell):
"""Rescale the used UV0 bbox to fill [0,1] (uniform scale, aspect kept).
Feet use a tiny corner of the body atlas; both the albedo and the mask
are authored here on UV0, so reclaiming the space is free texel density."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
uvl = bm.loops.layers.uv[0]
us, vs = [], []
for f in bm.faces:
for lo in f.loops:
us.append(lo[uvl].uv.x)
vs.append(lo[uvl].uv.y)
u0, u1, v0, v1 = min(us), max(us), min(vs), max(vs)
span = max(u1 - u0, v1 - v0)
if span < 1e-6:
bm.free()
log("WARNING: degenerate UV bbox — normalization skipped")
return
scale = 0.96 / span
for f in bm.faces:
for lo in f.loops:
uv = lo[uvl].uv
uv.x = 0.02 + (uv.x - u0) * scale
uv.y = 0.02 + (uv.y - v0) * scale
bm.to_mesh(me)
bm.free()
me.update()
log(f"normalized UV0: bbox ({u0:.3f},{v0:.3f})..({u1:.3f},{v1:.3f}) "
f"-> [0.02,0.98] (x{scale:.1f} density)")
# --------------------------------------------------------------------------
# Feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _paint_texels(px, py, pz, noise, lm):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions.
Regions (spec): sole -> R, upper -> G, toe cap -> B.
Painted lines (albedo only): toe cap line, sole welt stitch, topline edge
stitch, heel counter seam.
"""
n = px.shape[0]
tl = np.where(px >= 0.0, lm.topline.get("L", 1.0), lm.topline.get("R", 1.0))
w2 = lm.seam_w * 0.5
sole = pz < lm.sole_top
cap = (~sole) & (py <= lm.cap_y)
# --- albedo -------------------------------------------------------------
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = LEATHER_RGB[c] + noise
alb[:, 3] = 1.0
for c in range(3):
alb[sole, c] = SOLE_RGB[c] + noise[sole]
if not PLAIN:
capline = (~sole) & (np.abs(py - lm.cap_y) < w2)
welt = np.abs(pz - lm.sole_top) < w2
topstitch = (~sole) & (np.abs(pz - (tl - 3.0 * w2)) < w2)
heelseam = (~sole) & (py > 0.0) \
& (np.abs(py - lm.heel_seam_y) < w2)
thread = capline | welt | topstitch | heelseam
for c in range(3):
alb[thread, c] = STITCH_RGB[c]
# --- region mask: sole R / upper G / toe cap B ---------------------------
mask = np.zeros((n, 4), dtype=np.float32)
mask[sole, 0] = 1.0
mask[cap, 2] = 1.0
mask[~(sole | cap), 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 footwear 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] = LEATHER_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 = upper 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"shoes_albedo_{body}", albedo_path)
_save(mask_buf, f"shoes_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
# Re-save the albedo under the SHARED sidecar name and leave the image
# datablock pointing there: the glTF exporter derives the embedded image
# name from the filepath basename, so the GLB carries "base_albedo" and
# Godot's extract-on-import lands exactly on <body>_base_albedo.png (the
# wave-1 convention) instead of doubling to <body>_<body>_base_albedo.png.
# The pixels at base_albedo.png are THIS body's during its export; main()
# restores the reference body's copy after the loop.
shared_path = os.path.join(os.path.dirname(albedo_path), "base_albedo.png")
albedo_img.filepath_raw = shared_path
albedo_img.save()
return albedo_img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_shoes(body_dir, out_dir, body, offset, topline_lift):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
denim.weld_boundaries(shell) # zips upper + sole plate + ankle slivers
recalc_normals(shell)
lm = FootLandmarks(armature, shell.data)
ankle_cut_and_flatten(shell, lm, topline_lift)
relax_rim(shell, lm, RIM_RELAX_PASSES)
skin_bvh = snapshot_skin_bvh(shell) # BEFORE smoothing moves anything
smooth_toe_box(shell, lm)
sole_idx = classify_sole_verts(shell)
base.offset_outward(shell, offset)
toe_extra_offset(shell, lm, TOE_EXTRA_OFFSET_M)
enforce_clearance(shell, skin_bvh, offset)
flatten_sole(shell, sole_idx, lm.sole_plane)
base.solidify(shell, base.CLOTH_THICKNESS_M)
clamp_topline_residue(shell, lm)
if UV_NORMALIZE:
normalize_uv0(shell)
denim.author_parked_uv2(shell) # shoes are not logo-capable
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 CAP_BALL_FRAC, LEATHER_RGB, SOLE_RGB, SOLE_TOTAL_M, PLAIN
global UV_NORMALIZE
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] [--sole-mm M] [--topline-lift M] [--cap-frac F] "
"[--base-rgb r,g,b] [--sole-rgb r,g,b] [--plain] "
"[--no-uv-normalize]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = SHELL_OFFSET_M
topline_lift = TOPLINE_LIFT_M
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--sole-mm" in argv:
SOLE_TOTAL_M = float(argv[argv.index("--sole-mm") + 1])
if "--topline-lift" in argv:
topline_lift = float(argv[argv.index("--topline-lift") + 1])
if "--cap-frac" in argv:
CAP_BALL_FRAC = float(argv[argv.index("--cap-frac") + 1])
if "--base-rgb" in argv:
LEATHER_RGB = tuple(
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
if "--sole-rgb" in argv:
SOLE_RGB = tuple(
float(v) for v in argv[argv.index("--sole-rgb") + 1].split(","))
if "--plain" in argv:
PLAIN = True
if "--no-uv-normalize" in argv:
UV_NORMALIZE = False
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"shoes per-body mode: {len(bodies)} bodies, offset "
f"{offset * 1000:.0f} mm, sole {SOLE_TOTAL_M * 1000:.0f} mm, "
f"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_shoes(body_dir, out_dir, body, offset, topline_lift)
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()
@@ -0,0 +1,676 @@
"""
blender_author_slides.py (T-1089 wave 2, slides — open swim footwear)
Authors SLIDES (open sandal: flat sole slab + one broad strap band across the
midfoot) as per-body offset shells, reusing blender_author_offset_shell.py as
a library (scene build/join, offset, solidify, GLB export) and the denim
companion's required bottoms/footwear practices (boundary WELD of coincident
segment-seam verts before offsetting; the parked logo UV2 layer).
First offset-shell FOOTWEAR: follows the peasant_shoes both-feet convention
(one garment covers seg_foot_l + seg_foot_r), and adds what feet need that no
torso/leg companion provides, as reusable parameters:
* STRAP band cut — the foot shell is cut down to just the midfoot band
between two Y planes derived from that body's own foot bones
(fractions of the ball_l/r head -> foot_l/r head span, identical 65-bone
rig on all 11 bodies). Probe evidence: the seg_foot ankle rim (the
weight-threshold splitter's jagged boundary, 3.3-8.3 cm teeth post-weld)
stays ABOVE y-fraction ~0.62 of that span on every body, so a band cut at
<= 0.58 removes the entire jagged rim by construction. The band keeps the
full cross-section ring (including under-foot skin) so the strap-to-sole
join is gap-free by construction (the coverall waist-join pattern); the
hidden under-foot part is swallowed by the sole slab.
* clean strap rims by PLANE BISECT — the wave-1 delete-then-flatten rim
practice is too crude for the foot's large instep triangles (it notches
the strap crest); the band is instead cut with two exact bisect planes,
which yields the same clean-plane end state the denim flatten was after,
by construction (bmesh interpolates weights/UVs on the new edge verts).
A rim check verifies every open-edge vert sits on a cut plane.
* SOLE slab construction — per foot, the 2D convex hull (monotone chain) of
that foot's full footprint, expanded radially by a margin, extruded into
a prism from below the skin's lowest point to just above it (the foot
visually rests IN the footbed). Sole verts receive skin weights by
nearest-vertex transfer from the pre-cut foot snapshot, so the sole bends
with the foot/ball bones during Walk/Sprint toe-off. A FLEX CREASE ring
is bisected into each prism at the ball-joint line so the slab hinges
where the foot hinges (QA evidence: without it, toe skin dips through
the linearly-interpolated top face in deep-crouch toe-off).
* under-sole clamp — strap ring verts that offset/solidify pushed below the
sole interior are clamped onto a plane inside the slab (invisible), so the
strap never pokes out of the sole bottom.
* deterministic region UVs — the garment is fully re-UV'd (the foot's body
atlas layout is useless for garment paint): strap faces pack into the left
half of UV space, sole faces into the right half, each planar-projected by
dominant normal axis into three stacked tiles. Faces of one region may
overlap in UV (they share one flat colour), but strap and sole texels are
DISJOINT by construction — no cross-region contamination, no operator
(bpy.ops.uv.*) dependency in background mode.
* albedo + region mask painted together per face: sole -> R (tint_0),
strap -> G (tint_1); bright default albedo (spec: swim family, bright).
Painted texels are dilated outward so bilinear/mip bleed at tile edges
never lands on an untinted texel.
Usage (per-body only — offset shells author per body, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_slides.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/slides \
[--bodies average_m,child,...] [--offset 0.005] \
[--strap-y0 0.10] [--strap-y1 0.58] \
[--sole-margin 0.007] [--sole-embed 0.009] [--sole-drop 0.010] \
[--strap-rgb 0.93,0.35,0.20] [--sole-rgb 0.82,0.83,0.85] [--seed N]
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 + the denim companion (shared machinery)
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load(name, fname):
spec = importlib.util.spec_from_file_location(name, os.path.join(_HERE, fname))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
base = _load("offset_shell_base", "blender_author_offset_shell.py")
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
log = base.log
# --------------------------------------------------------------------------
# Parameters
# --------------------------------------------------------------------------
COVERED_SEGMENTS = ["seg_foot_l", "seg_foot_r"]
STRAP_OFFSET_M = 0.005 # strap standoff from skin (slides hug the foot)
STRAP_THICKNESS_M = 0.005 # Solidify thickness (chunky foam strap)
# Strap band window as fractions of the ball-head -> foot(ankle)-head Y span,
# measured from the ball. Probe: the jagged seg_foot ankle rim starts at
# fraction ~0.62 on every body, so y1 <= 0.58 excludes it by construction.
STRAP_Y0_FRAC = 0.10
STRAP_Y1_FRAC = 0.58
# Sole slab (metres on average_m; scaled by each body's foot-length ratio).
SOLE_MARGIN_M = 0.007 # radial footprint expansion beyond the skin hull
SOLE_EMBED_M = 0.009 # slab top above the foot's lowest skin point
SOLE_DROP_M = 0.010 # slab bottom below the foot's lowest skin point
CLAMP_INSET_M = 0.003 # strap under-foot verts clamped this far above
# the slab bottom (kept inside the sole)
# Bright default (spec: swim family). Texture carries identity; the runtime
# toon_garment.gdshader recolors per region via luma, so these tones ARE the
# default look and default_tints should match them.
STRAP_RGB = (0.93, 0.35, 0.20) # bright coral strap (G region, tint_1)
SOLE_RGB = (0.82, 0.83, 0.85) # off-white foam sole (R region, tint_0)
ALBEDO_NOISE = 0.018 # +/- jitter, subtle foam/EVA feel
NOISE_SEED = 3089
TEX_SIZE = 512 # slides are small; 512 is plenty
DILATE_PX = 6 # painted-texel dilation into the background
_REF_FOOT_SPAN = 0.2211 # average_m: foot_l head y (0.0875) - ball_l tail y (-0.1336)
# --------------------------------------------------------------------------
# Per-body landmarks
# --------------------------------------------------------------------------
class FootLandmarks:
"""Per-side cut/sole parameters from one body's own foot bones + mesh."""
def __init__(self, armature):
bones = armature.data.bones
self.sides = {}
spans = []
for side, sign in (("l", +1), ("r", -1)):
foot = bones.get(f"foot_{side}")
ball = bones.get(f"ball_{side}")
if foot is None or ball is None:
raise RuntimeError(
f"foot_{side}/ball_{side} missing — not the 65-bone rig?")
ankle_y = foot.head_local.y
ball_y = ball.head_local.y
toe_y = ball.tail_local.y
span = ankle_y - toe_y
spans.append(span)
self.sides[sign] = {
"y0": ball_y + STRAP_Y0_FRAC * (ankle_y - ball_y),
"y1": ball_y + STRAP_Y1_FRAC * (ankle_y - ball_y),
"ball_y": ball_y,
}
self.scale = (sum(spans) / len(spans)) / _REF_FOOT_SPAN
for sign in (+1, -1):
s = self.sides[sign]
log(f"landmarks side {'L' if sign > 0 else 'R'}: "
f"strap y[{s['y0']:.4f},{s['y1']:.4f}]")
log(f"foot scale vs average_m: {self.scale:.3f}")
# --------------------------------------------------------------------------
# Geometry: snapshot, strap cut, rim flatten, sole build
# --------------------------------------------------------------------------
def snapshot_skin(shell):
"""Record post-weld skin verts: positions + per-vertex group weights.
Used later for the sole's nearest-vertex weight transfer, after the strap
cut has thrown most of the foot away.
"""
me = shell.data
pos = np.array([(v.co.x, v.co.y, v.co.z) for v in me.vertices],
dtype=np.float64)
weights = [
[(g.group, g.weight) for g in v.groups if g.weight > 0.0]
for v in me.vertices
]
return pos, weights
def strap_cut(shell, lm):
"""Cut the foot shell down to the strap band with two exact plane bisects.
The wave-1 bottoms practice (delete-then-flatten) is too crude here: the
foot mesh's instep triangles are large relative to the 5-7 cm band, so
vertex deletion notches the strap crest and rim-snapping folds it. Bisect
planes give clean straight rims BY CONSTRUCTION (bmesh interpolates
weights/UVs on the new edge verts), which is the same end state the denim
flatten was after. The rig's feet are mirrored, so one Y window (side
average) serves both feet.
"""
y0 = (lm.sides[+1]["y0"] + lm.sides[-1]["y0"]) / 2.0
y1 = (lm.sides[+1]["y1"] + lm.sides[-1]["y1"]) / 2.0
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.bisect_plane(
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
plane_co=(0.0, y0, 0.0), plane_no=(0.0, 1.0, 0.0),
clear_inner=True) # drop y < y0 (toe side)
bmesh.ops.bisect_plane(
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
plane_co=(0.0, y1, 0.0), plane_no=(0.0, 1.0, 0.0),
clear_outer=True) # drop y > y1 (ankle side, incl. the jagged rim)
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"strap band bisect y[{y0:.4f},{y1:.4f}]: {before} -> "
f"{len(shell.data.vertices)} verts")
if len(shell.data.vertices) == 0:
raise RuntimeError("strap cut removed everything — window wrong?")
def check_strap_rims(shell, lm):
"""Verify the band's open edges sit ON the two cut planes (bisect gives
this by construction; residue means ankle-rim leakage into the window)."""
y0 = (lm.sides[+1]["y0"] + lm.sides[-1]["y0"]) / 2.0
y1 = (lm.sides[+1]["y1"] + lm.sides[-1]["y1"]) / 2.0
bm = bmesh.new()
bm.from_mesh(shell.data)
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)
off = [i for i in boundary
if min(abs(bm.verts[i].co.y - y0), abs(bm.verts[i].co.y - y1)) > 1e-4]
bm.free()
log(f"strap rims: {len(boundary)} boundary verts, {len(off)} off-plane "
f"(expected 0)")
if off:
log("WARNING: off-plane rim verts — ankle-rim residue inside the "
"band window; lower STRAP_Y1_FRAC")
def _convex_hull_2d(points):
"""Andrew's monotone chain; returns CCW hull points (numpy (H,2))."""
pts = np.unique(np.round(points, 6), axis=0)
order = np.lexsort((pts[:, 1], pts[:, 0]))
pts = pts[order]
if len(pts) < 3:
raise RuntimeError("degenerate footprint for convex hull")
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lower = []
for p in pts:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
lower.pop()
lower.append(p)
upper = []
for p in pts[::-1]:
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
upper.pop()
upper.append(p)
return np.array(lower[:-1] + upper[:-1], dtype=np.float64)
def build_soles(shell, skin_pos, lm, margin, embed, drop):
"""Append one sole prism per foot; returns (sole_start_index, sole_info).
Footprint = expanded convex hull of that foot's FULL skin xy (pre-cut
snapshot); prism spans z in [skin_min - drop, skin_min + embed].
FLEX CREASE: each prism is bisected (no material removed) by a Y plane at
that side's ball-joint line. Without it the long heel->toe top face
interpolates skinning linearly across the whole span while the foot
creases sharply at the ball during Sprint/Crouch toe-off — the slab sags
below the bend crest and toe skin dips through (QA evidence, wave-2
slides). The crease ring picks up ball-blended weights from the
nearest-vertex transfer, so the slab hinges where the foot hinges.
"""
me = shell.data
sole_start = len(me.vertices)
bm = bmesh.new()
bm.from_mesh(me)
info = {}
for sign in (+1, -1):
if sign > 0:
side_pos = skin_pos[skin_pos[:, 0] >= 0.0]
else:
side_pos = skin_pos[skin_pos[:, 0] < 0.0]
if len(side_pos) == 0:
raise RuntimeError("no skin verts on one side for sole build")
hull = _convex_hull_2d(side_pos[:, :2])
centroid = hull.mean(axis=0)
d = hull - centroid
n = d / np.linalg.norm(d, axis=1, keepdims=True)
hull = hull + n * margin
z_min = float(side_pos[:, 2].min())
z_bot, z_top = z_min - drop, z_min + embed
info[sign] = {"z_bot": z_bot, "z_top": z_top}
bot = [bm.verts.new((p[0], p[1], z_bot)) for p in hull]
top = [bm.verts.new((p[0], p[1], z_top)) for p in hull]
new_faces = []
h = len(hull)
for i in range(h):
j = (i + 1) % h
new_faces.append(bm.faces.new((bot[i], bot[j], top[j], top[i])))
new_faces.append(bm.faces.new(top))
new_faces.append(bm.faces.new(tuple(reversed(bot))))
bmesh.ops.recalc_face_normals(bm, faces=new_faces)
# Flex crease at the ball line (cut only, keep both sides).
crease_verts = set()
for f in new_faces:
crease_verts.update(f.verts)
crease_edges = {e for v in crease_verts for e in v.link_edges}
res = bmesh.ops.bisect_plane(
bm,
geom=list(crease_verts) + list(crease_edges) + new_faces,
plane_co=(0.0, lm.sides[sign]["ball_y"], 0.0),
plane_no=(0.0, 1.0, 0.0),
clear_inner=False, clear_outer=False)
cut = sum(1 for g in res["geom_cut"] if isinstance(g, bmesh.types.BMVert))
log(f"sole {'L' if sign > 0 else 'R'}: hull {h} pts, "
f"z[{z_bot:.4f},{z_top:.4f}], ball crease "
f"y={lm.sides[sign]['ball_y']:.4f} ({cut} crease verts)")
bm.to_mesh(me)
bm.free()
me.update()
log(f"soles appended: verts {sole_start} -> {len(me.vertices)}")
return sole_start, info
def transfer_sole_weights(shell, skin_pos, skin_weights, sole_start):
"""Nearest-vertex weight transfer (same-side skin snapshot) for sole verts."""
me = shell.data
left = skin_pos[:, 0] >= 0.0
idx_by_side = {+1: np.where(left)[0], -1: np.where(~left)[0]}
transferred = 0
for vi in range(sole_start, len(me.vertices)):
co = me.vertices[vi].co
side = +1 if co.x >= 0.0 else -1
cand = idx_by_side[side]
d2 = ((skin_pos[cand] - np.array([co.x, co.y, co.z])) ** 2).sum(axis=1)
src = int(cand[int(np.argmin(d2))])
for gi, w in skin_weights[src]:
shell.vertex_groups[gi].add([vi], w, 'REPLACE')
transferred += 1
log(f"sole weights transferred: {transferred} verts (nearest skin vert)")
def clamp_strap_under_sole(shell, sole_start, sole_info):
"""Clamp strap verts that dipped below the sole interior back inside it."""
me = shell.data
n = 0
for vi in range(sole_start):
v = me.vertices[vi]
side = +1 if v.co.x >= 0.0 else -1
floor_z = sole_info[side]["z_bot"] + CLAMP_INSET_M
if v.co.z < floor_z:
v.co.z = floor_z
n += 1
me.update()
if n:
log(f"clamped {n} strap verts above the sole bottom (hidden in slab)")
# --------------------------------------------------------------------------
# Deterministic region UVs (no bpy.ops dependency)
# --------------------------------------------------------------------------
def _dominant_axis(normal):
a = (abs(normal.x), abs(normal.y), abs(normal.z))
return a.index(max(a))
def author_region_uvs(shell, sole_start):
"""Re-UV the garment: strap faces -> left half, sole faces -> right half,
each split into three stacked tiles by dominant normal axis (planar
projection). Faces within one (region, axis) tile may overlap — harmless,
they share one flat colour — but strap/sole texels never mix."""
me = shell.data
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
if len(me.uv_layers) == 0:
me.uv_layers.new(name="UVMap")
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
bm.normal_update()
uvl = bm.loops.layers.uv[0]
# region 0 = strap (u 0.02..0.48), region 1 = sole (u 0.52..0.98)
u_ranges = {0: (0.02, 0.48), 1: (0.52, 0.98)}
proj = {0: (1, 2), 1: (0, 2), 2: (0, 1)} # axis -> (coord_a, coord_b)
buckets = {}
for face in bm.faces:
region = 1 if all(v.index >= sole_start for v in face.verts) else 0
axis = _dominant_axis(face.normal)
buckets.setdefault((region, axis), []).append(face)
for (region, axis), faces in buckets.items():
ca, cb = proj[axis]
pts = []
for f in faces:
for lo in f.loops:
pts.append((lo.vert.co[ca], lo.vert.co[cb]))
pts = np.array(pts)
lo_a, hi_a = float(pts[:, 0].min()), float(pts[:, 0].max())
lo_b, hi_b = float(pts[:, 1].min()), float(pts[:, 1].max())
da = max(hi_a - lo_a, 1e-6)
db = max(hi_b - lo_b, 1e-6)
u0, u1 = u_ranges[region]
v0 = 0.02 + axis * (1.0 / 3.0)
v1 = v0 + (1.0 / 3.0) - 0.04
for f in faces:
for lo in f.loops:
a = (lo.vert.co[ca] - lo_a) / da
b = (lo.vert.co[cb] - lo_b) / db
lo[uvl].uv = (u0 + a * (u1 - u0), v0 + b * (v1 - v0))
bm.to_mesh(me)
bm.free()
me.update()
log(f"region UVs authored: {len(buckets)} (region,axis) tiles")
# --------------------------------------------------------------------------
# Paint albedo + mask (per-face flat colours, shared rasterization)
# --------------------------------------------------------------------------
def _raster_tri_multi(bufs_colors, a, b, c, W, H):
"""Barycentric fill of one UV triangle into several (buf, color) pairs."""
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]
px = xs + 0.5
py = ys + 0.5
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
w2 = 1.0 - w0 - w1
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
if not inside.any():
return
for buf, color in bufs_colors:
region = buf[miny:maxy + 1, minx:maxx + 1]
region[inside] = np.array(color, dtype=buf.dtype)
def _dilate_painted(alb, mask, flag, iters):
"""Grow painted texels into the background so bilinear/mip bleed at tile
edges picks up real region colours, not the background fill."""
H, W = flag.shape
for _ in range(iters):
grew = np.zeros_like(flag)
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
src = np.zeros_like(flag)
sy0, sy1 = max(dy, 0), H + min(dy, 0)
ty0, ty1 = max(-dy, 0), H + min(-dy, 0)
sx0, sx1 = max(dx, 0), W + min(dx, 0)
tx0, tx1 = max(-dx, 0), W + min(-dx, 0)
src[ty0:ty1, tx0:tx1] = flag[sy0:sy1, sx0:sx1]
fill = (~flag) & (~grew) & src
if not fill.any():
continue
alb_src = np.zeros_like(alb)
alb_src[ty0:ty1, tx0:tx1] = alb[sy0:sy1, sx0:sx1]
mask_src = np.zeros_like(mask)
mask_src[ty0:ty1, tx0:tx1] = mask[sy0:sy1, sx0:sx1]
alb[fill] = alb_src[fill]
mask[fill] = mask_src[fill]
grew |= fill
flag |= grew
def paint_albedo_and_mask(shell, sole_start, albedo_path, mask_path, body):
"""Rasterize all UV0 triangles once: sole -> R + sole tone, strap -> G +
strap tone. One classification drives both outputs (denim practice)."""
W = H = TEX_SIZE
alb_buf = np.empty((H, W, 4), dtype=np.float32)
for c in range(3):
alb_buf[:, :, c] = STRAP_RGB[c]
alb_buf[:, :, 3] = 1.0
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
mask_buf[:, :, 1] = 1.0 # background = strap green (bleed-safe default)
flag_buf = np.zeros((H, W), dtype=bool)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
uvl = bm.loops.layers.uv[0]
counts = {"sole": 0, "strap": 0}
for face in bm.faces:
is_sole = all(v.index >= sole_start for v in face.verts)
counts["sole" if is_sole else "strap"] += 1
alb_rgb = SOLE_RGB if is_sole else STRAP_RGB
mask_rgba = (1.0, 0.0, 0.0, 0.0) if is_sole else (0.0, 1.0, 0.0, 0.0)
alb_rgba = (alb_rgb[0], alb_rgb[1], alb_rgb[2], 1.0)
uvs = [lo[uvl].uv.copy() for lo in face.loops]
flag_view = flag_buf[:, :, None] # view — writes reach flag_buf
for i in range(1, len(uvs) - 1):
_raster_tri_multi(
[(alb_buf, alb_rgba), (mask_buf, mask_rgba),
(flag_view, True)],
uvs[0], uvs[i], uvs[i + 1], W, H)
bm.free()
total = max(sum(counts.values()), 1)
log("region faces: " + " ".join(
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
_dilate_painted(alb_buf, mask_buf, flag_buf, DILATE_PX)
rng = np.random.default_rng(NOISE_SEED)
noise = ((rng.random((H, W, 1), dtype=np.float32) - 0.5)
* 2.0 * ALBEDO_NOISE)
alb_buf[:, :, :3] = np.clip(alb_buf[:, :, :3] + noise, 0.0, 1.0)
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"slides_albedo_{body}", albedo_path)
_save(mask_buf, f"slides_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
# The glTF exporter names the embedded image after the file basename, and
# the Godot import EXTRACTS it as <glb>_<imagename>.png. Point the image at
# the shared base_albedo.png so the extraction lands exactly on the sidecar
# saved above (<body>_base_albedo.png, same pixels — hoodie/tshirt
# convention), instead of a doubled <body>_<body>_base_albedo.png whose
# deletion would break the imported scene. The shared file is re-pointed
# to the reference body's paint at the end of the run (main()).
albedo_img.filepath_raw = os.path.join(os.path.dirname(albedo_path),
"base_albedo.png")
albedo_img.save()
return albedo_img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_slides(body_dir, out_dir, body, offset):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
denim.weld_boundaries(shell) # merge UV-seam/segment duplicate verts
lm = FootLandmarks(armature)
skin_pos, skin_weights = snapshot_skin(shell)
strap_cut(shell, lm)
check_strap_rims(shell, lm)
base.offset_outward(shell, offset)
base.solidify(shell, STRAP_THICKNESS_M)
sole_start, sole_info = build_soles(
shell, skin_pos, lm,
SOLE_MARGIN_M * lm.scale, SOLE_EMBED_M * lm.scale,
SOLE_DROP_M * lm.scale)
transfer_sole_weights(shell, skin_pos, skin_weights, sole_start)
clamp_strap_under_sole(shell, sole_start, sole_info)
author_region_uvs(shell, sole_start)
denim.author_parked_uv2(shell) # slides are not logo-capable
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, sole_start, 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 STRAP_Y0_FRAC, STRAP_Y1_FRAC, STRAP_RGB, SOLE_RGB
global SOLE_MARGIN_M, SOLE_EMBED_M, SOLE_DROP_M, NOISE_SEED
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] [--strap-y0 F] [--strap-y1 F] [--sole-margin M] "
"[--sole-embed M] [--sole-drop M] [--strap-rgb r,g,b] "
"[--sole-rgb r,g,b] [--seed N]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
def _f(flag, default):
return float(argv[argv.index(flag) + 1]) if flag in argv else default
def _rgb(flag, default):
if flag not in argv:
return default
return tuple(float(v) for v in argv[argv.index(flag) + 1].split(","))
offset = _f("--offset", STRAP_OFFSET_M)
STRAP_Y0_FRAC = _f("--strap-y0", STRAP_Y0_FRAC)
STRAP_Y1_FRAC = _f("--strap-y1", STRAP_Y1_FRAC)
SOLE_MARGIN_M = _f("--sole-margin", SOLE_MARGIN_M)
SOLE_EMBED_M = _f("--sole-embed", SOLE_EMBED_M)
SOLE_DROP_M = _f("--sole-drop", SOLE_DROP_M)
NOISE_SEED = int(_f("--seed", NOISE_SEED))
STRAP_RGB = _rgb("--strap-rgb", STRAP_RGB)
SOLE_RGB = _rgb("--sole-rgb", SOLE_RGB)
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"slides per-body mode: {len(bodies)} bodies, strap offset "
f"{offset * 1000:.0f} mm, band frac [{STRAP_Y0_FRAC},{STRAP_Y1_FRAC}]")
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_slides(body_dir, out_dir, body, offset)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
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()
@@ -0,0 +1,765 @@
"""
blender_author_sneakers.py (T-1089 wave 2, sneakers_modern + trainer family)
Authors low-top TRAINERS as per-body offset shells over BOTH feet (seg_foot_l +
seg_foot_r joined into ONE garment — the peasant_shoes both-feet convention),
reusing blender_author_offset_shell.py as a library (scene build, join, offset,
solidify, GLB export) and blender_author_denim_pants.py's boundary weld.
What footwear needs beyond the bottoms family, as reusable parameters:
* boundary WELD first (denim practice) — the raw foot segment is NOT one
surface: the sole is a separate coincident-vert patch and the ankle cut
leaves floating shards (probe: average_m = 4 islands, muscular_m/child = 8).
remove_doubles at 0.5 mm fuses everything into one watertight-except-ankle
shell per foot; weights identical by origin, so skinning is unaffected.
* COLLAR cut + rim flatten — the segment splitter's ankle boundary is jagged
weight-threshold teeth (3.3-8.3 cm across bodies, back-biased). Verts above
the collar plane (a fraction of the ankle-joint height) are deleted, then
every remaining boundary vert is pulled ONTO the collar plane — a clean
horizontal low-top opening. Re-flattened after the outward offset because
rim-vert normals have a +z bias that would lift the rim.
* SOLE slab (this family's new offset-shell param) — the rim-flatten practice
applied to the ground plane, built as cut + flatten + extrude + fill: the
shell's underside band is cut away (with the toe-knuckle lobes that survive
smoothing), the open rim is flattened onto the cut plane and its outline
relaxed, then extruded straight down to a plane `--sole-drop` (scaled per
body) below the body's own foot-bottom and closed with a flat bottom — a
clean prism slab; Solidify thickens it and a final clamp guarantees the
outer sole is planar. (Snapping the band in place instead collapses mesh
rows into crumpled slivers — melted-wax scallops on the probe renders.)
* TOE-BOX SMOOTHING + ROUNDING — the body feet have INDIVIDUAL TOES; a raw
offset shell reads as a foot-shaped slipper (verified on average_m). Heavy
iterative vertex smoothing over the toe region (feathered, boundary rim
pinned) melts the toe creases into one volume, a light global pass
de-lumps the ankle anatomy, then an extra normal-along inflation feathered
toward the toe tip restores the lost volume as a rounded sneaker toe box.
* COLLAR FLARE — small feathered radial stand-off at the opening (the jeans
waist-flare practice) for ankle-flex clearance under Walk/Crouch.
* TEXEL-level feature painting (denim practice): one analytic field drives
BOTH the albedo and the region mask, so they always agree:
- albedo: painted lace cross-straps over a darker tongue panel, toe cap
+ border line, foxing stripe at the sole top, heel tab, collar band,
vamp + heel-counter panel lines (swoosh-free — no brand marks).
Everyday default: white/grey, flat toon-friendly tones.
- mask: sole -> R, upper -> G, laces + trim (collar band, toe cap,
heel tab) -> B (spec: sole=R, upper=G, laces+trim=B).
* a parked logo_uv TEXCOORD_1 layer (all UVs at (2,2)) — not logo-capable,
but toon_garment.gdshader samples UV2 unconditionally.
All parameters derive PER BODY from that body's own bone landmarks (foot_l /
ball_l) and measured mesh extents (foot length, half-width, ground plane),
scaled by foot length against the hand-calibrated average_m reference — the
same proportional-ratio philosophy as base.derive_thresholds. Per-body mode
only (offset shells author per body, Q-060). Left/right feet share body-atlas
UV space whose islands may overlap; every painted feature is |x|-mirror
symmetric, so overlapping texels agree by construction.
Usage (sneakers_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_sneakers.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/sneakers_modern \
[--bodies average_m,child,...] [--offset 0.009] [--sole-drop 0.015] \
[--sole-snap-frac 0.55] [--collar-frac 0.95] [--collar-flare 0.002] \
[--toe-round 0.004] [--toe-smooth 12] [--laces 4] [--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 + the denim module (weld utility)
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load(mod_name, file_name):
spec = importlib.util.spec_from_file_location(
mod_name, os.path.join(_HERE, file_name))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
base = _load("offset_shell_base", "blender_author_offset_shell.py")
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
log = base.log
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (toes point -Y)
# --------------------------------------------------------------------------
# Parameters (CLI-overridable ones are module globals)
# --------------------------------------------------------------------------
COVERED_SEGMENTS = ["seg_foot_l", "seg_foot_r"]
OFFSET_M = 0.009 # shoe standoff — snugger than cloth (12 mm)
SOLE_DROP_M = 0.015 # sole slab depth below the body's own foot bottom
# (scaled by foot length per body)
SOLE_SNAP_FRAC = 0.55 # sole CUT height as a fraction of the sole rise
# (ground -> foxing top): everything below is cut
# away and rebuilt as an extruded prism slab (see
# build_sole_slab) — kills the toe-knuckle underside
# lobes that survive smoothing (probe, average_m)
COLLAR_FRAC = 0.95 # collar plane as fraction of ankle-joint height
COLLAR_FLARE_M = 0.002 # radial stand-off at the opening (ankle-flex room)
TOE_ROUND_M = 0.004 # extra feathered inflation at the toe box
SMOOTH_GLOBAL_ITERS = 2 # light whole-shell de-lumping passes
SMOOTH_TOE_ITERS = 18 # heavy toe-crease melting passes (12 left visible
# knuckle grooves on the probe renders)
SMOOTH_FACTOR = 0.5
N_LACES = 4 # painted cross-straps
PLAIN = False # --plain: flat upper, no painted features
TEX_SIZE = 1024 # painted lace/panel lines need > 512
NOISE_SEED = 3089
# Vertical proportions — fractions of the collar height above the ground.
SOLE_TOP_FRAC = 0.30 # sole sidewall (foxing) top -> R region below this
COLLAR_BAND_FRAC = 0.15 # collar trim band height (B region)
VAMP_LINE_FRAC = 0.42 # side panel line height between sole top and collar
# Foot-axis proportions — fractions of foot length / half-width.
TOE_CAP_FRAC = 0.18 # toe cap depth from the toe tip (B region)
HEEL_LINE_FRAC = 0.22 # heel-counter panel line from the heel tip
LACE_T0, LACE_T1 = 0.18, 0.80 # lace panel span along the foot bone
LACE_HALFW_FRAC = 0.40 # lace panel half-width, of foot half-width
LACE_STRIPE_DUTY = 0.44 # stripe thickness as a fraction of stripe spacing
LINE_W_M = 0.0035 # painted panel/border line half-width (scaled)
HEEL_TAB_HALFW_M = 0.012 # heel tab half-width (scaled)
# Everyday default: white/grey, flat toon-friendly tones (sRGB floats).
UPPER_RGB = (0.880, 0.880, 0.890)
SOLE_RGB = (0.780, 0.790, 0.800)
TREAD_RGB = (0.450, 0.460, 0.480) # below-ground outsole
TOE_RGB = (0.920, 0.920, 0.930) # toe bumper
LACE_RGB = (0.960, 0.960, 0.965)
TONGUE_SHADE = 0.90 # lace-zone panel behind the straps
COLLAR_SHADE = 0.88 # collar band darkening
HEEL_TAB_SHADE = 0.72
LINE_SHADE = 0.74 # painted panel/border lines
ALBEDO_NOISE = 0.015
# Reference proportions (average_m) the fractions were calibrated against.
_REF_FOOT_LEN = 0.2704 # heel y (0.1374) - toe tip y (-0.1330)
# --------------------------------------------------------------------------
# Per-body landmarks
# --------------------------------------------------------------------------
class FootLandmarks:
"""Cut/mask/paint parameters from one body's foot bones + measured mesh."""
def __init__(self, armature, shell):
bones = armature.data.bones
foot = bones.get("foot_l")
ball = bones.get("ball_l")
if foot is None or ball is None:
raise RuntimeError("foot_l/ball_l missing — not the 65-bone rig?")
# Bone landmarks (left foot; the right mirrors via |x|).
self.ankle_y = foot.head_local.y
self.ankle_z = foot.head_local.z
self.ball_y = foot.tail_local.y
self.ball_z = foot.tail_local.z
self.toe_y = ball.tail_local.y
# Measured mesh extents (left-foot verts; feet are x-mirror symmetric).
lx = [v.co.x for v in shell.data.vertices if v.co.x > 0.0]
ly = [v.co.y for v in shell.data.vertices if v.co.x > 0.0]
zs = [v.co.z for v in shell.data.vertices]
self.foot_cx = (min(lx) + max(lx)) / 2.0
self.half_w = (max(lx) - min(lx)) / 2.0
self.heel_y = max(ly)
self.toe_tip_y = min(ly)
self.ground_z = min(zs)
self.foot_len = self.heel_y - self.toe_tip_y
self.s = self.foot_len / _REF_FOOT_LEN
self.collar_z = self.ground_z + COLLAR_FRAC * (self.ankle_z - self.ground_z)
self.sole_drop = SOLE_DROP_M * self.s
self.sole_bottom = self.ground_z - self.sole_drop
rise = self.collar_z - self.ground_z
self.sole_top = self.ground_z + SOLE_TOP_FRAC * rise
self.band_h = COLLAR_BAND_FRAC * rise
self.vamp_z = self.sole_top + VAMP_LINE_FRAC * (self.collar_z - self.sole_top)
self.cap_y = self.toe_tip_y + TOE_CAP_FRAC * self.foot_len
self.heel_line_y = self.heel_y - HEEL_LINE_FRAC * self.foot_len
self.lace_halfw = LACE_HALFW_FRAC * self.half_w
self.line_w = LINE_W_M * self.s
log(f"landmarks: ankle_z={self.ankle_z:.4f} collar_z={self.collar_z:.4f} "
f"ground={self.ground_z:.4f} sole_bottom={self.sole_bottom:.4f} "
f"sole_top={self.sole_top:.4f} foot_len={self.foot_len:.4f} "
f"half_w={self.half_w:.4f} cx={self.foot_cx:.4f} s={self.s:.3f}")
# --------------------------------------------------------------------------
# Geometry: collar cut + flatten, sole slab, toe round, collar flare
# --------------------------------------------------------------------------
def make_normals_consistent(shell):
"""Outward-consistent normals BEFORE the offset — the raw foot's sole patch
winds independently of the upper, so post-weld normals need one recalc or
the offset would pull the sole inward."""
bpy.ops.object.select_all(action='DESELECT')
shell.select_set(True)
bpy.context.view_layer.objects.active = shell
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
log("recalculated outward-consistent normals")
def collar_cut(shell, collar_z):
"""Delete verts above the collar plane (bone-plane-cut practice)."""
bm = bmesh.new()
bm.from_mesh(shell.data)
doomed = [v for v in bm.verts if v.co.z > collar_z]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"collar cut at z={collar_z:.4f}: removed {len(doomed)} verts")
def flatten_collar_rims(shell, collar_z, label):
"""Pull every open-boundary vert ONTO the collar plane (rim-flatten
practice). After the weld + collar cut the only open boundary is the two
collar rims, so a single pass flattens both feet."""
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)
if not boundary:
bm.free()
raise RuntimeError("no open collar boundary found after cut")
zs = [bm.verts[i].co.z for i in boundary]
for i in boundary:
bm.verts[i].co.z = collar_z
bm.to_mesh(me)
bm.free()
me.update()
log(f"flattened collar rims ({label}): {len(boundary)} verts, "
f"z {min(zs):.4f}..{max(zs):.4f} -> {collar_z:.4f}")
def smooth_shell(shell, lm):
"""Melt the individual toes into one toe box and de-lump the ankle
anatomy: iterative vertex smoothing, heavier toward the toe, with the open
collar boundary pinned so the flattened rim stays put. Runs pre-offset;
the 9 mm standoff + toe inflation restore the smoothing's volume loss."""
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)
interior = [v for v in bm.verts if v.index not in boundary]
toe_lim = lm.cap_y + 0.10 * lm.foot_len
toe_verts = [v for v in interior if v.co.y < toe_lim]
for _ in range(SMOOTH_GLOBAL_ITERS):
bmesh.ops.smooth_vert(bm, verts=interior, factor=SMOOTH_FACTOR,
use_axis_x=True, use_axis_y=True, use_axis_z=True)
for _ in range(SMOOTH_TOE_ITERS):
bmesh.ops.smooth_vert(bm, verts=toe_verts, factor=SMOOTH_FACTOR,
use_axis_x=True, use_axis_y=True, use_axis_z=True)
bm.to_mesh(me)
bm.free()
me.update()
log(f"smoothed shell: {SMOOTH_GLOBAL_ITERS} global passes "
f"({len(interior)} verts), {SMOOTH_TOE_ITERS} toe passes "
f"({len(toe_verts)} verts, y < {toe_lim:.4f})")
def toe_round(shell, lm, amount):
"""Extra normal-along inflation feathered toward the toe tip — swallows
the foot's toe detail into one rounded sneaker toe box."""
if amount <= 0.0:
return
span = lm.cap_y - lm.toe_tip_y
if span <= 1e-6:
return
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.normal_update()
n = 0
for v in bm.verts:
if v.co.y < lm.cap_y:
t = min((lm.cap_y - v.co.y) / span, 1.0)
v.co += v.normal * (amount * t)
n += 1
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"toe rounding: {n} verts, +{amount * 1000:.1f} mm feathered")
RIM_RELAX_ITERS = 3 # along-ring XY relaxation of the cut rim outline
def build_sole_slab(shell, lm):
"""Rim-flatten practice applied to the GROUND plane, done PROPERLY as a
cut + flatten + extrude (snapping a whole z-band onto the plane collapses
multiple mesh rows into crumpled slivers that read as melted-wax scallops
— probe renders v3-v5):
1. DELETE everything below the cut height (SOLE_SNAP_FRAC of the sole
rise) — removes the toe-knuckle underside lobes outright.
2. RIM-FLATTEN the resulting open bottom boundary onto the cut plane
(exactly the denim ankle practice), then relax the ring outline in
XY along the ring only — a smooth footprint curve, rounded toe.
3. EXTRUDE the ring straight down to the sole plane — a clean vertical
prism wall (extruded verts inherit the rim verts' deform weights,
so the sole still flexes at the ball joint).
4. FILL the bottom ring with faces — a closed flat underside.
Solidify then grows the outer surface CLOTH_THICKNESS_M further down,
landing the visible outsole on lm.sole_bottom (guaranteed by
clamp_residue after solidify)."""
plane = lm.sole_bottom + base.CLOTH_THICKNESS_M
hi = lm.ground_z + SOLE_SNAP_FRAC * (lm.sole_top - lm.ground_z)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
# 1. cut
doomed = [v for v in bm.verts if v.co.z < hi]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
# 2. rim-flatten (the collar rim is also open — split boundaries by z)
boundary = {v for e in bm.edges if len(e.link_faces) == 1
for v in e.verts}
z_mid = (lm.collar_z + hi) / 2.0
sole_rim = {v for v in boundary if v.co.z < z_mid}
if not sole_rim:
bm.free()
raise RuntimeError("no sole rim found after cut — check cut height")
for v in sole_rim:
v.co.z = hi
# along-ring XY relax: average each rim vert with its ring neighbours
# only (bmesh smooth_vert would pull toward the upper rows and shrink)
for _ in range(RIM_RELAX_ITERS):
new_pos = {}
for v in sole_rim:
ring_nbrs = [e.other_vert(v) for e in v.link_edges
if len(e.link_faces) == 1
and e.other_vert(v) in sole_rim]
if len(ring_nbrs) >= 2:
ax = sum(n.co.x for n in ring_nbrs) / len(ring_nbrs)
ay = sum(n.co.y for n in ring_nbrs) / len(ring_nbrs)
new_pos[v] = (v.co.x + 0.5 * (ax - v.co.x),
v.co.y + 0.5 * (ay - v.co.y))
for v, (x, y) in new_pos.items():
v.co.x = x
v.co.y = y
# 3. extrude the rim edges straight down to the sole plane
rim_edges = [e for e in bm.edges if len(e.link_faces) == 1
and e.verts[0] in sole_rim and e.verts[1] in sole_rim]
ret = bmesh.ops.extrude_edge_only(bm, edges=rim_edges)
new_verts = [g for g in ret["geom"]
if isinstance(g, bmesh.types.BMVert)]
for v in new_verts:
v.co.z = plane
# 4. close the bottom
bottom_edges = [e for e in bm.edges if len(e.link_faces) == 1
and all(abs(v.co.z - plane) < 1e-6 for v in e.verts)]
filled = bmesh.ops.holes_fill(bm, edges=bottom_edges, sides=0)
# 5. UV-park the new faces. The fill n-gon's default loop UVs span the
# whole enclosed UV region — its texel bake overwrites painted islands
# (probe v6: mottled patches, features erased); wall quads' copied UVs
# sit ON the island boundary where bilinear sampling picks up
# background. ONE park point per foot (per-face points sample texels of
# varying paint state and stripe the wall — probe v7): all new faces on
# a side park on the UV centre of that side's LARGEST-UV-area
# rim-adjacent face — big enough that the bake reliably rasterizes its
# interior, and its centre lies in the sole band (z <= sole_top), so
# the sampled texel is the flat sole tone with an R-region mask.
uv_layer = bm.loops.layers.uv[0] if len(bm.loops.layers.uv) else None
if uv_layer is not None:
new_faces = [g for g in ret["geom"]
if isinstance(g, bmesh.types.BMFace)]
new_faces += list(filled["faces"])
new_face_set = set(new_faces)
best = {} # x-sign side -> (uv_area, centre uv); feet never cross x=0
for v in sole_rim:
side = 1 if v.co.x >= 0.0 else -1
for loop in v.link_loops:
f = loop.face
if f in new_face_set:
continue
us = [lp[uv_layer].uv for lp in f.loops]
area = 0.0
for i in range(len(us)):
j = (i + 1) % len(us)
area += us[i].x * us[j].y - us[j].x * us[i].y
area = abs(area) * 0.5
if side not in best or area > best[side][0]:
best[side] = (area,
(sum(u.x for u in us) / len(us),
sum(u.y for u in us) / len(us)))
for f in new_faces:
side = 1 if sum(v.co.x for v in f.verts) >= 0.0 else -1
if side not in best:
side = -side
uv = best[side][1]
for loop in f.loops:
loop[uv_layer].uv = uv
bm.to_mesh(me)
bm.free()
me.update()
log(f"sole slab: cut {len(doomed)} verts (z < {hi:.4f}), rim "
f"{len(sole_rim)} verts -> z={hi:.4f}, extruded {len(new_verts)} "
f"verts -> z={plane:.4f}, filled {len(filled['faces'])} bottom faces")
def collar_flare(shell, lm, flare):
"""Feathered radial stand-off at the opening (waist-flare practice) —
ankle-flex clearance under Walk/Crouch."""
if flare <= 0.0:
return
z0 = lm.collar_z - 2.0 * lm.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 * lm.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"collar flare: {n} verts, +{flare * 1000:.1f} mm radial at rim")
def clamp_residue(shell, lm):
"""Post-solidify safety clamps: the rim cap can push verts above the
collar plane, and blended edge normals leave the outsole slightly uneven —
squash both back onto their planes."""
me = shell.data
n_top = n_bot = 0
for v in me.vertices:
if v.co.z > lm.collar_z:
v.co.z = lm.collar_z
n_top += 1
elif v.co.z < lm.sole_bottom:
v.co.z = lm.sole_bottom
n_bot += 1
me.update()
log(f"clamped residue: {n_top} collar verts -> {lm.collar_z:.4f}, "
f"{n_bot} sole verts -> {lm.sole_bottom:.4f}")
# --------------------------------------------------------------------------
# Sneaker feature field (texel-level; drives albedo AND mask together).
# Every feature is |x|-mirror symmetric so the (possibly overlapping)
# left/right UV islands paint identical values.
# --------------------------------------------------------------------------
def _paint_texels(px, py, pz, noise, lm):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
n = px.shape[0]
side = np.where(px >= 0.0, 1.0, -1.0)
dx = px - side * lm.foot_cx # signed offset from the foot centre
adx = np.abs(dx)
in_sole = pz <= lm.sole_top
# Tread = the flat underside plane only (never visible from the side).
# The sole SIDE WALL is snapped geometry whose UV triangles are stretched
# slivers — any tonal variation there (noise, a dark tread band) smears
# into vertical streaks under bilinear magnification, so the whole band
# above the underside is painted ONE flat tone.
in_tread = pz <= lm.sole_bottom + 0.0015
in_band = (pz >= lm.collar_z - lm.band_h) & ~in_sole
# Lace panel: param t along the foot bone (ankle head -> ball tail) in
# the (y,z) plane; texels above the bone line, near the centreline.
dy_ax = lm.ball_y - lm.ankle_y
dz_ax = lm.ball_z - lm.ankle_z
l2 = dy_ax * dy_ax + dz_ax * dz_ax
t = ((py - lm.ankle_y) * dy_ax + (pz - lm.ankle_z) * dz_ax) / l2
above_bone = pz > (lm.ankle_z + t * dz_ax + 0.002 * lm.s)
in_tongue = (~in_sole & above_bone & (adx < lm.lace_halfw)
& (t >= LACE_T0) & (t <= LACE_T1))
frac = (t - LACE_T0) / (LACE_T1 - LACE_T0)
stripe_pos = frac * N_LACES
stripe_d = np.abs(stripe_pos - (np.floor(stripe_pos) + 0.5))
laces = in_tongue & (stripe_d < 0.5 * LACE_STRIPE_DUTY)
toe_cap = (py < lm.cap_y) & ~in_sole
heel_tab = ((adx < HEEL_TAB_HALFW_M * lm.s) & ~in_sole
& (py > lm.ankle_y + 0.55 * (lm.heel_y - lm.ankle_y)))
# --- albedo -------------------------------------------------------------
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = UPPER_RGB[c] + noise
alb[:, 3] = 1.0
for c in range(3):
alb[in_sole, c] = SOLE_RGB[c] # flat, noise-free (sliver UVs)
alb[in_tread, c] = TREAD_RGB[c] # underside plane only
if not PLAIN:
for c in range(3):
alb[toe_cap & ~in_sole, c] = TOE_RGB[c] + noise[toe_cap & ~in_sole]
alb[in_tongue, :3] *= TONGUE_SHADE
for c in range(3):
alb[laces, c] = LACE_RGB[c] + noise[laces]
alb[in_band & ~laces, :3] *= COLLAR_SHADE
alb[heel_tab & ~in_band, :3] *= HEEL_TAB_SHADE
# Painted panel lines (albedo only — swoosh-free).
foxing = np.abs(pz - lm.sole_top) < lm.line_w
cap_border = (np.abs(py - lm.cap_y) < lm.line_w) & ~in_sole
vamp = ((np.abs(pz - lm.vamp_z) < lm.line_w) & ~in_sole
& (adx > lm.lace_halfw * 0.8)
& (py > lm.cap_y) & (py < lm.heel_line_y))
heel_ctr = ((np.abs(py - lm.heel_line_y) < lm.line_w) & ~in_sole
& (pz < lm.collar_z - lm.band_h))
lines = (foxing | cap_border | vamp | heel_ctr) & ~laces
alb[lines, :3] *= LINE_SHADE
# --- region mask: sole R / upper G / laces+trim B -------------------------
mask = np.zeros((n, 4), dtype=np.float32)
is_b = (laces | toe_cap | heel_tab | in_band) & ~in_sole
mask[in_sole, 0] = 1.0
mask[is_b, 2] = 1.0
mask[~(in_sole | is_b), 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 sneaker 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] = UPPER_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 = upper 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()
counts = [float(mask_buf[:, :, c].sum()) for c in range(3)]
total = max(sum(counts), 1.0)
log(f"painted {tri_count} UV triangles ({W}x{H}); mask texels "
f"R={100 * counts[0] / total:.1f}% G={100 * counts[1] / total:.1f}% "
f"B={100 * counts[2] / total:.1f}%")
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"sneaker_albedo_{body}", albedo_path)
_save(mask_buf, f"sneaker_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
return albedo_img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_sneaker_shell(body_dir, out_dir, body, offset):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
denim.weld_boundaries(shell) # fuse sole patch + ankle shards
make_normals_consistent(shell)
lm = FootLandmarks(armature, shell)
collar_cut(shell, lm.collar_z)
smooth_shell(shell, lm)
flatten_collar_rims(shell, lm.collar_z, "pre-offset")
base.offset_outward(shell, offset)
flatten_collar_rims(shell, lm.collar_z, "post-offset") # rim normals lift it
toe_round(shell, lm, TOE_ROUND_M * lm.s)
build_sole_slab(shell, lm)
collar_flare(shell, lm, COLLAR_FLARE_M * lm.s)
base.solidify(shell, base.CLOTH_THICKNESS_M)
clamp_residue(shell, lm)
denim.author_parked_uv2(shell) # not logo-capable; shader needs UV2
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 OFFSET_M, SOLE_DROP_M, COLLAR_FRAC, COLLAR_FLARE_M
global TOE_ROUND_M, N_LACES, PLAIN, SOLE_SNAP_FRAC, SMOOTH_TOE_ITERS
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] [--sole-drop M] [--sole-snap-frac F] "
"[--collar-frac F] [--collar-flare M] [--toe-round M] "
"[--toe-smooth N] [--laces N] [--plain]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
if "--offset" in argv:
OFFSET_M = float(argv[argv.index("--offset") + 1])
if "--sole-drop" in argv:
SOLE_DROP_M = float(argv[argv.index("--sole-drop") + 1])
if "--sole-snap-frac" in argv:
SOLE_SNAP_FRAC = float(argv[argv.index("--sole-snap-frac") + 1])
if "--toe-smooth" in argv:
SMOOTH_TOE_ITERS = int(argv[argv.index("--toe-smooth") + 1])
if "--collar-frac" in argv:
COLLAR_FRAC = float(argv[argv.index("--collar-frac") + 1])
if "--collar-flare" in argv:
COLLAR_FLARE_M = float(argv[argv.index("--collar-flare") + 1])
if "--toe-round" in argv:
TOE_ROUND_M = float(argv[argv.index("--toe-round") + 1])
if "--laces" in argv:
N_LACES = int(argv[argv.index("--laces") + 1])
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"sneaker per-body mode: {len(bodies)} bodies, offset "
f"{OFFSET_M * 1000:.0f} mm, sole-drop {SOLE_DROP_M * 1000:.0f} mm, "
f"collar-frac {COLLAR_FRAC}, laces {N_LACES}, 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_sneaker_shell(body_dir, out_dir, body, OFFSET_M)
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()
@@ -0,0 +1,597 @@
"""
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()
@@ -0,0 +1,472 @@
"""
blender_author_swim_trunks.py (T-1089 wave 2, swim set — swim_trunks)
Authors bright SWIM TRUNKS as per-body offset shells: hips + upper legs with
the hem clearly ABOVE the knee (shorter than shorts_modern's 0.78 thigh
fraction), a painted centre-front drawstring (knot + two hanging cords) and a
lateral SIDE PANEL colour block down each leg.
Regions (RGBA mask, toon_garment.gdshader):
waistband -> R (tint_0), body -> G (tint_1), side panel -> B (tint_2)
Companion to blender_author_offset_shell.py (imported as a library — scene
build/join, offset, solidify, GLB export). The bottoms-specific practices are
REUSED from the proven wave-1 companions, not rediscovered:
* denim (blender_author_denim_pants.py): boundary WELD of coincident
segment-seam rings before offsetting (un-welded rings offset apart along
diverging normals -> cracks), open-rim FLATTENING onto clean planes (the
segment splitter leaves 4.5-7.6 cm jagged teeth at the waist/hem rims),
the feathered --waist-flare deep-crouch mitigation, the post-solidify
waist residue clamp, and the parked logo TEXCOORD_1 layer (trunks are not
logo-capable, but toon_garment.gdshader samples UV2 unconditionally).
* legs (blender_author_offset_shell_legs.py): the bone-plane hem cut.
Like the denim script, albedo and region mask are painted TEXEL-level from
ONE analytic feature-field evaluation per texel (UV0 triangles rasterized
with barycentric-interpolated 3D positions), so the painted drawstring/panel
and the recolor regions always agree:
- albedo: bright saturated default (coral body, teal side panel), painted
drawstring knot + hanging cords (centre front), waistband border
+ hem border stitches, side-panel edge piping — flat and
toon-friendly, identity carried by the texture (style pin:
modern only).
- mask: waistband R, body G, side panel B.
All cut/mask/paint parameters derive PER BODY from that body's own bone
landmarks (thigh_l/r) and measured mesh extents (waist rim valley, hem
plane), scaled by the body's garment span and hip half-width — the same
proportional-ratio philosophy as base.derive_thresholds. Per-body mode only
(offset shells author per body, Q-060).
Usage (swim_trunks reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_swim_trunks.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/swim_trunks \
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 0.60] \
[--band-frac 0.14] [--panel-frac 0.42] [--waist-flare 0.007] \
[--base-rgb 0.95,0.45,0.35] [--panel-rgb 0.13,0.68,0.64] [--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 wave-1 modules (shared machinery — reused, not copied)
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load(mod_name, file_name):
spec = importlib.util.spec_from_file_location(
mod_name, os.path.join(_HERE, file_name))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
base = _load("offset_shell_base", "blender_author_offset_shell.py")
denim = _load("denim_pants", "blender_author_denim_pants.py")
legs = _load("offset_shell_legs", "blender_author_offset_shell_legs.py")
log = base.log
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
# --------------------------------------------------------------------------
# Parameters (defaults = swim_trunks)
# --------------------------------------------------------------------------
COVERED_SEGMENTS = ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"]
HEM_FRAC = 0.60 # fraction of the thigh bone KEPT below its head.
# 0.60 puts the hem ~17 cm above the knee on
# average_m — clearly shorter than shorts_modern's
# 0.78 (~9 cm above the knee), per the swim spec.
TEX_SIZE = 1024 # albedo + mask resolution (painted details need >512)
WAIST_FLARE_M = 0.007 # feathered radial stand-off at the waistband rim
# (deep-crouch waist-fold mitigation — QA evidence
# from the peasant + jeans sets, reused from denim)
# Vertical proportions — fractions of the garment span (waist_z - hem_z).
BAND_FRAC = 0.14 # waistband height (R region) (~4.4 cm on average_m)
SEAM_W_FRAC = 0.019 # painted stitch/piping line width (~6 mm on average_m)
HEM_STITCH_FRAC = 0.028 # hem border stitch centre height above the hem
CORD_LEN_FRAC = 0.20 # drawstring cord drop below the knot (~6.3 cm)
# Horizontal proportions — fractions of the hip half-width (|thigh head x|).
PANEL_HW_FRAC = 0.42 # side-panel half ARC width (~3.8 cm -> 7.6 cm panel)
# Drawstring metrics in metres on average_m, scaled by the body's hip ratio.
KNOT_R_M = 0.009 # knot blob radius
CORD_W_M = 0.0055 # cord line width
CORD_X0_M = 0.011 # cord |x| offset just below the knot
CORD_SLANT = 0.28 # outward drift of the cords per metre of drop
# Swim style (sRGB floats; saved as-is — matches the proven base pipeline).
# Bright saturated default per spec: coral body / teal side panel. Luma of
# both stays near the toon_garment.gdshader recolor sweet spot (~0.5-0.6).
CORAL_RGB = (0.95, 0.45, 0.35) # body + waistband base (luma ~0.59)
TEAL_RGB = (0.13, 0.68, 0.64) # side panel (luma ~0.51)
TRIM_RGB = (0.97, 0.95, 0.90) # drawstring + stitches/piping (cream)
BAND_SHADE = 0.93 # waistband albedo darkening (reads untinted)
ALBEDO_NOISE = 0.020 # +/- woven jitter
PLAIN = False # --plain: skip drawstring/stitch/piping paint
NOISE_SEED = 3089
# Reference proportions (average_m) the fractions were calibrated against:
# waist rim valley 1.0312, hem(0.60) 0.7139 -> span 0.3173; |thigh head x|.
_REF_HIP_X = 0.0906
# --------------------------------------------------------------------------
# Per-body landmarks
# --------------------------------------------------------------------------
class TrunkLandmarks:
"""Cut/mask/paint parameters derived from one body's bones + mesh."""
def __init__(self, armature):
bones = armature.data.bones
thigh_l = bones.get("thigh_l")
thigh_r = bones.get("thigh_r")
if thigh_l is None or thigh_r is None:
raise RuntimeError("thigh_l/thigh_r missing — not the 65-bone rig?")
self.hip_x = (abs(thigh_l.head_local.x) + abs(thigh_r.head_local.x)) / 2.0
# Hem plane: fraction of the thigh bone kept below its head (averaged
# over both sides — they are symmetric on every shipped body).
self.hem_z = (
thigh_l.head_local.z + HEM_FRAC * (thigh_l.tail_local.z - thigh_l.head_local.z)
+ thigh_r.head_local.z + HEM_FRAC * (thigh_r.tail_local.z - thigh_r.head_local.z)
) / 2.0
# Leg axis control points (z-increasing: knee -> hip) for the azimuth
# side-panel placement. x values are the +x (left) leg; the right leg
# mirrors via sign.
self.leg_z_pts = np.array([thigh_l.tail_local.z, thigh_l.head_local.z])
self.leg_x_pts = np.array(
[abs(thigh_l.tail_local.x), abs(thigh_l.head_local.x)])
self.leg_y_pts = np.array([thigh_l.tail_local.y, thigh_l.head_local.y])
log(f"landmarks: hem plane z={self.hem_z:.3f} "
f"(thigh head {thigh_l.head_local.z:.3f} -> knee "
f"{thigh_l.tail_local.z:.3f}, keep {HEM_FRAC:.2f}) "
f"hip_x={self.hip_x:.3f}")
def finalize(self, waist_plane, hem_plane):
"""Derive paint metrics from the CLEAN (flattened) rims."""
self.waist_z = waist_plane
self.hem_plane = hem_plane
self.span = waist_plane - hem_plane
self.sh = self.hip_x / _REF_HIP_X
self.band_h = BAND_FRAC * self.span
self.band_z = waist_plane - self.band_h
self.seam_w = SEAM_W_FRAC * self.span
self.panel_hw = PANEL_HW_FRAC * self.hip_x
self.hem_stitch_z = hem_plane + HEM_STITCH_FRAC * self.span
self.knot_z = self.band_z + 0.55 * self.band_h
self.knot_r = KNOT_R_M * self.sh
self.cord_len = CORD_LEN_FRAC * self.span
self.cord_w = CORD_W_M * self.sh
self.cord_x0 = CORD_X0_M * self.sh
log(f"finalized: waist={self.waist_z:.3f} hem={self.hem_plane:.3f} "
f"span={self.span:.3f} band_z={self.band_z:.3f} "
f"panel_hw={self.panel_hw * 100:.1f}cm "
f"seam_w={self.seam_w * 1000:.1f}mm knot_z={self.knot_z:.3f}")
# --------------------------------------------------------------------------
# Swim feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _trunk_field(px, py, pz, lm):
"""Evaluate swim-trunk features at texel 3D positions (numpy arrays).
Returns bool arrays (in_band, panel, trim):
`in_band`/`panel` feed the mask R/B channels; `trim` is every painted
cream detail (drawstring, border stitches, panel edge piping).
"""
w2 = lm.seam_w * 0.5
front = py * FRONT_Y_SIGN > 0.004
in_band = pz >= lm.band_z
mid = ~in_band
# Per-z leg axis, mirrored by x sign (clamps to the hip values above the
# thigh head, so the panel runs straight up through the hip to the band).
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
# Side panel: azimuth toward the outer (+/-x) direction; constant metric
# width via arc distance (same construction as the denim outseam).
arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
panel = mid & (arc_out < lm.panel_hw)
# Painted trim: panel edge piping, waistband border, hem border.
piping = mid & (np.abs(arc_out - lm.panel_hw) < w2 * 0.8)
wstitch = np.abs(pz - lm.band_z) < w2 * 0.7
hstitch = np.abs(pz - lm.hem_stitch_z) < w2 * 0.7
# Drawstring: knot blob + two cords hanging from it, centre front,
# drifting slightly outward as they drop.
knot = front & (np.hypot(px, pz - lm.knot_z) < lm.knot_r)
drop = np.clip(lm.knot_z - pz, 0.0, None)
cord_sep = lm.cord_x0 + CORD_SLANT * drop
cords = front & (pz < lm.knot_z) & (pz > lm.knot_z - lm.cord_len) \
& (np.abs(np.abs(px) - cord_sep) < lm.cord_w * 0.5)
trim = piping | wstitch | hstitch | knot | cords
return in_band, panel, trim
def _paint_texels(px, py, pz, noise, lm):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
n = px.shape[0]
in_band, panel, trim = _trunk_field(px, py, pz, lm)
# --- albedo: coral base, shaded band, teal panel, cream trim ------------
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = CORAL_RGB[c] + noise
alb[:, 3] = 1.0
alb[in_band, :3] *= BAND_SHADE
for c in range(3):
alb[panel, c] = TEAL_RGB[c] + noise[panel]
if not PLAIN:
alb[trim, 0] = TRIM_RGB[0]
alb[trim, 1] = TRIM_RGB[1]
alb[trim, 2] = TRIM_RGB[2]
np.clip(alb, 0.0, 1.0, out=alb)
# --- region mask: waistband R / body G / side panel B --------------------
mask = np.zeros((n, 4), dtype=np.float32)
mask[in_band, 0] = 1.0
mask[panel, 2] = 1.0
mask[~(in_band | panel), 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 swim field, write albedo + mask together (same rasterizer
contract as the denim companion, driving this garment's field)."""
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] = CORAL_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 = body green (bilinear-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()
total = float(W * H)
r_pct = 100.0 * float((mask_buf[:, :, 0] > 0.5).sum()) / total
b_pct = 100.0 * float((mask_buf[:, :, 2] > 0.5).sum()) / total
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H}); "
f"mask texels: R(waistband)={r_pct:.1f}% B(panel)={b_pct:.1f}% "
f"(rest G/background)")
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"swim_albedo_{body}", albedo_path)
_save(mask_buf, f"swim_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
return albedo_img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_trunks_shell(body_dir, out_dir, body, offset):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
denim.weld_boundaries(shell)
lm = TrunkLandmarks(armature)
legs.hem_cut(shell, lm.hem_z)
waist_plane, hem_plane = denim.flatten_open_rims(shell, lm.hem_z)
base.offset_outward(shell, offset)
denim.waist_flare(shell, waist_plane,
BAND_FRAC * (waist_plane - hem_plane), WAIST_FLARE_M)
base.solidify(shell, base.CLOTH_THICKNESS_M)
denim.clamp_waist_residue(shell, waist_plane)
# Paint metrics reference the CLEAN rims (band under the flattened waist
# edge, hem stitch above the flattened hem).
lm.finalize(waist_plane, hem_plane)
denim.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, BAND_FRAC, PANEL_HW_FRAC, WAIST_FLARE_M
global CORAL_RGB, TEAL_RGB, PLAIN
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] [--band-frac F] [--panel-frac F] "
"[--waist-flare M] [--base-rgb r,g,b] [--panel-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 "--band-frac" in argv:
BAND_FRAC = float(argv[argv.index("--band-frac") + 1])
if "--panel-frac" in argv:
PANEL_HW_FRAC = float(argv[argv.index("--panel-frac") + 1])
if "--waist-flare" in argv:
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
if "--base-rgb" in argv:
CORAL_RGB = tuple(
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
if "--panel-rgb" in argv:
TEAL_RGB = tuple(
float(v) for v in argv[argv.index("--panel-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"swim-trunks per-body mode: {len(bodies)} bodies, offset "
f"{offset * 1000:.0f} mm, hem-frac {HEM_FRAC}, band-frac {BAND_FRAC}, "
f"panel-frac {PANEL_HW_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_trunks_shell(body_dir, out_dir, body, offset)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
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()
@@ -0,0 +1,649 @@
"""
blender_author_swimsuit.py (T-1089 wave 2, swimsuit_onepiece — swim family)
Authors a one-piece SWIMSUIT as per-body offset shells, reusing
blender_author_offset_shell.py as a library (scene build, join, offset,
solidify, GLB export) and blender_author_offset_coverall.py's per-texel
rasterizer (_tri_texels) for crisp mask/albedo boundaries. Coverage: torso +
torso_upper + hips — NO arms, NO legs. The torso_upper is cut down to narrow
SHOULDER STRAPS (front scoop + back scoop + open armholes); the bottom gets
HIGH-CUT leg openings that rise from the crotch gusset to the hip line at the
sides. What this companion adds, as reusable parameters:
* seg_leg_upper_l/r are INCLUDED in the covered set purely as cut stock:
the seg_hips lower boundary is splitter teeth (probe: 9-13 cm jag,
z 0.884..1.015 on average_m — worse than the waist teeth denim flattens).
Welding the legs in makes that seam interior, so the high-cut leg cut
slices through CLEAN thigh geometry and no natural teeth survive; the
tubes below the cut are deleted whole.
* measurement-guarded straps: the strap corridor is proportional
(STRAP_X0/X1_FRAC of shoulder |x|) but clamped per body against the
MEASURED neck-seam ring (max |x| + guard) and armscye ring (min |x|
guard), because the armscye reaches |x| = 0.79..0.92 x shoulder depending
on body fork (probe) and a fixed fraction would slice into the seam.
* measurement-derived armhole plane: z_arm = (armscye ring min z) drop,
so the whole jagged arm-seam ring is guaranteed deleted on every body.
* analytic high-cut leg surface z_leg(x, y): crotch gusset (below-crotch at
|x| < gusset half-width, so the gusset never opens), rising outward to
the thigh-head line, with a front/back blend that keeps the seat covered
(RISE_BACK < RISE_FRONT). The SAME function drives the cut, the rim
flattening and the trim band in the mask, so they always agree.
* open-rim FLATTENING onto the analytic lines (denim practice, post-offset):
scoop rims -> scoop plane, armhole rims -> z_arm, leg rims -> z_leg,
strap side edges -> |x| snapped to the exact strap planes.
Regions (RGBA mask, toon_garment.gdshader; spec: straps+trim=R, body=G,
side color-block=B):
R = shoulder straps + edge trim along every opening (scoop/armhole/leg)
G = main body fabric
B = side color-block panels (normal-gated: |n.x| >= BLOCK_NX_MIN, below the
armholes) — the saturated-color-block default lives in the tints.
Painted albedo: flat per-region luminance + woven noise + dark stitch lines on
region boundaries (toon-friendly; identity carried by the texture). A parked
logo_uv TEXCOORD_1 layer ships for channel consistency (not logo-capable).
Per-body mode only (offset shells author per body, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_swimsuit.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/swimsuit_onepiece \
[--bodies average_m,child,...] [--offset 0.012]
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 os
import shutil
import sys
import bmesh
import bpy
import numpy as np
# Make the sibling modules importable when Blender runs this file directly.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import blender_author_offset_shell as base # noqa: E402
import blender_author_offset_coverall as cov # noqa: E402 (per-texel rasterizer)
log = base.log
FRONT = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
# --------------------------------------------------------------------------
# Parameters
# --------------------------------------------------------------------------
COVERED_SEGMENTS = [
"seg_torso", "seg_torso_upper", "seg_hips",
# cut stock only — welded in so the hips|leg seam teeth become interior,
# then everything below the high-cut line is deleted.
"seg_leg_upper_l", "seg_leg_upper_r",
]
WELD_DIST = 5e-4 # boundary weld tolerance (0.5 mm, denim practice)
MIN_LOOP_VERTS = 6 # ignore sliver boundary loops when measuring rings
# Straps — proportional corridor over the shoulder top, clamped per body
# against the measured neck ring (outside it) and armscye ring (inside it).
STRAP_X0_FRAC = 0.52 # inner strap edge, fraction of shoulder |x|
STRAP_X1_FRAC = 0.74 # outer strap edge
STRAP_MIN_W_FRAC = 0.10 # minimum acceptable strap width (of shoulder |x|)
NECK_GUARD_M = 0.004 # keep this far outside the neck-seam ring
ARM_GUARD_M = 0.004 # keep this far inside the armscye ring
# Necklines — fractions of the spine_01->neck_01 span above spine_01 head.
FRONT_SCOOP_FRAC = 0.66 # front neckline (covers the seg_torso top band)
BACK_SCOOP_FRAC = 0.54 # back scoop, slightly deeper
ARMHOLE_DROP_M_REF = 0.015 # armhole plane below the measured armscye min z
# High-cut leg openings — all spans derive from (thigh head z crotch z).
GUSSET_HALF_FRAC = 0.50 # crotch gusset half-width, fraction of thigh |x|
LEG_OUT_X_FRAC = 1.55 # |x| where the rise reaches its max (of thigh |x|)
LEG_RISE_FRONT_FRAC = 1.00 # front/side rise: up to the thigh-head line
LEG_RISE_BACK_FRAC = 0.58 # back rise: lower, keeps the seat covered
LEG_RISE_POW = 1.35 # >1 = convex sweep (classic high-cut)
LEG_YBLEND_FRAC = 0.35 # front/back blend half-width (of thigh |x|)
GUSSET_DROP_M_REF = 0.004 # gusset cut sits below the crotch -> never opens
# Region mask / painted albedo.
TRIM_W_M_REF = 0.016 # edge-trim band width along the openings
BLOCK_NX_MIN = 0.74 # side panel: |normal.x| threshold (normal-gated)
ALBEDO_LUMA = {"body": 0.62, "trim": 0.56, "block": 0.67}
STITCH_LUMA = 0.30
ALBEDO_NOISE = 0.02
NOISE_SEED = 3089
LABELS = ["body", "trim", "block"]
LABEL_ID = {name: i for i, name in enumerate(LABELS)}
LABEL_RGBA_ARR = np.array(
[
(0.0, 1.0, 0.0, 0.0), # body -> G
(1.0, 0.0, 0.0, 0.0), # trim -> R (straps + edge trim)
(0.0, 0.0, 1.0, 0.0), # block -> B (side color-block)
],
dtype=np.float32,
)
LABEL_LUMA_ARR = np.array([ALBEDO_LUMA[n] for n in LABELS], dtype=np.float32)
_REF_SHOULDER_X = 0.1919 # average_m upperarm head |x| (same anchor as base)
# --------------------------------------------------------------------------
# Per-body landmarks + measured rings
# --------------------------------------------------------------------------
class SuitLandmarks:
"""Cut/mask parameters from one body's bones, crotch probe and rings."""
def __init__(self, armature, crotch_z):
bones = armature.data.bones
def bone(name):
b = bones.get(name)
if b is None:
raise RuntimeError(f"landmark bone {name} missing")
return b
ua_l, ua_r = bone("upperarm_l"), bone("upperarm_r")
self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
self.scale = self.shoulder_x / _REF_SHOULDER_X
neck = bone("neck_01")
spine01 = bone("spine_01")
span = neck.head_local.z - spine01.head_local.z
self.scoop_front = spine01.head_local.z + FRONT_SCOOP_FRAC * span
self.scoop_back = spine01.head_local.z + BACK_SCOOP_FRAC * span
thigh = bone("thigh_l")
self.thigh_x = abs(thigh.head_local.x)
self.thigh_z = thigh.head_local.z
self.crotch_z = crotch_z
self.rise_span = self.thigh_z - self.crotch_z
self.gx = GUSSET_HALF_FRAC * self.thigh_x
self.out_x = LEG_OUT_X_FRAC * self.thigh_x
self.yb = LEG_YBLEND_FRAC * self.thigh_x
self.gusset_drop = GUSSET_DROP_M_REF * self.scale
self.trim_w = TRIM_W_M_REF * self.scale
# Filled by apply_ring_measurements():
self.sx0 = STRAP_X0_FRAC * self.shoulder_x
self.sx1 = STRAP_X1_FRAC * self.shoulder_x
self.z_arm = self.scoop_front # placeholder until rings are measured
def apply_ring_measurements(self, neck_max_ax, arm_min_ax, arm_min_z):
self.sx0 = max(STRAP_X0_FRAC * self.shoulder_x,
neck_max_ax + NECK_GUARD_M * self.scale)
self.sx1 = min(STRAP_X1_FRAC * self.shoulder_x,
arm_min_ax - ARM_GUARD_M * self.scale)
min_w = STRAP_MIN_W_FRAC * self.shoulder_x
if self.sx1 - self.sx0 < min_w:
log(f"WARNING: strap corridor pinched "
f"({(self.sx1 - self.sx0) * 1000:.1f} mm) — widening inward")
self.sx0 = max(neck_max_ax + NECK_GUARD_M * self.scale,
self.sx1 - min_w)
self.z_arm = arm_min_z - ARMHOLE_DROP_M_REF * self.scale
log(f"landmarks: straps |x|=[{self.sx0:.3f},{self.sx1:.3f}] "
f"scoop_f={self.scoop_front:.3f} scoop_b={self.scoop_back:.3f} "
f"z_arm={self.z_arm:.3f} crotch={self.crotch_z:.3f} "
f"thigh_z={self.thigh_z:.3f} gusset<|x|<{self.gx:.3f} "
f"trim={self.trim_w * 1000:.1f}mm")
# ---- analytic surfaces (numpy-vectorised; scalars work too) ----------
def z_leg(self, x, y):
"""High-cut leg-opening surface: gusset floor below the crotch,
rising outward to the thigh-head line; back rises less (seat)."""
t = np.clip((np.abs(x) - self.gx) / max(self.out_x - self.gx, 1e-6),
0.0, 1.0) ** LEG_RISE_POW
f = np.clip((np.asarray(y) * FRONT + self.yb) / (2.0 * self.yb),
0.0, 1.0)
rise = self.rise_span * (
LEG_RISE_BACK_FRAC + (LEG_RISE_FRONT_FRAC - LEG_RISE_BACK_FRAC) * f)
return (self.crotch_z - self.gusset_drop) + t * rise
def scoop(self, y):
"""Neckline level: front scoop on the front side, back scoop behind."""
return np.where(np.asarray(y) * FRONT > 0.0,
self.scoop_front, self.scoop_back)
def probe_crotch(body_dir):
"""Import seg_hips alone to measure the crotch (its lowest point) exactly
(denim practice — the joined mesh's min z is the knee cut stock)."""
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)
# --------------------------------------------------------------------------
# Geometry: weld, ring measurement, cuts, rim flattening
# --------------------------------------------------------------------------
def weld_boundaries(shell):
"""Merge coincident segment-boundary verts so the offset can't open cracks
(weights/UVs identical on coincident verts, so skinning is unaffected)."""
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)
bm.to_mesh(me)
bm.free()
me.update()
log(f"welded segment boundaries: {before} -> {len(me.vertices)} verts")
def _boundary_loops(bm):
"""Connected open-boundary loops as lists of vert indices."""
adj = {}
for e in bm.edges:
if len(e.link_faces) != 1:
continue
a, b = e.verts[0].index, e.verts[1].index
adj.setdefault(a, set()).add(b)
adj.setdefault(b, set()).add(a)
seen = set()
loops = []
for start in adj:
if start in seen:
continue
stack, comp = [start], []
while stack:
v = stack.pop()
if v in seen:
continue
seen.add(v)
comp.append(v)
stack.extend(adj[v] - seen)
loops.append(comp)
return loops
def measure_rings(shell, lm):
"""Locate the neck-seam and armscye boundary rings on the welded shell.
Expected loops: neck ring, 2 armscye rings, 2 knee rings (cut stock;
median z below the crotch — ignored). Sliver loops (< MIN_LOOP_VERTS)
are skipped. Returns (neck_max_ax, arm_min_ax, arm_min_z).
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
candidates = []
for comp in _boundary_loops(bm):
if len(comp) < MIN_LOOP_VERTS:
continue
zs = sorted(bm.verts[i].co.z for i in comp)
med_z = zs[len(zs) // 2]
if med_z < lm.crotch_z:
continue # knee ring on the leg cut stock
axs = [abs(bm.verts[i].co.x) for i in comp]
candidates.append({
"med_ax": sorted(axs)[len(axs) // 2],
"max_ax": max(axs),
"min_ax": min(axs),
"min_z": min(bm.verts[i].co.z for i in comp),
"n": len(comp),
})
bm.free()
if len(candidates) < 3:
raise RuntimeError(
f"expected neck + 2 armscye rings, found {len(candidates)}")
candidates.sort(key=lambda c: c["med_ax"])
neck = candidates[0]
arms = candidates[-2:]
arm_min_ax = min(a["min_ax"] for a in arms)
arm_min_z = min(a["min_z"] for a in arms)
log(f"rings: neck max|x|={neck['max_ax']:.3f} ({neck['n']}v) "
f"armscye min|x|={arm_min_ax:.3f} min z={arm_min_z:.3f}")
return neck["max_ax"], arm_min_ax, arm_min_z
def suit_cuts(shell, lm):
"""Delete everything the swimsuit doesn't cover:
- neckline scoops between the straps (|x| < sx0, z above scoop level)
- armholes outside the straps (|x| > sx1, z above the armhole plane)
- legs below the analytic high-cut surface z_leg(x, y)
The strap corridor (sx0 <= |x| <= sx1) survives over the shoulder."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
doomed = []
for v in bm.verts:
x, y, z = v.co.x, v.co.y, v.co.z
ax = abs(x)
if z < float(lm.z_leg(x, y)):
doomed.append(v)
elif ax < lm.sx0 and z > float(lm.scoop(y)):
doomed.append(v)
elif ax > lm.sx1 and z > lm.z_arm:
doomed.append(v)
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"suit cuts removed {len(doomed)} verts; "
f"{len(shell.data.vertices)} remain")
def flatten_rims(shell, lm):
"""Pull every open rim onto its analytic line (post-offset, denim
practice): leg rims -> z_leg surface, scoop rims -> scoop plane, armhole
rims -> z_arm plane, strap side edges -> |x| snapped to the strap planes.
Only boundary verts move; weights/UVs ride along.
The leg/top split plane sits MID-GAP between the thigh line (the leg
rims' analytic maximum) and the lowest top opening (armhole plane or
back scoop) — a dead zone with no legitimate boundary verts. A tight
margin (thigh_z + 2 cm) is NOT enough: the 12 mm outward offset runs
before flattening and drifts the high-cut side-apex verts upward, and on
thin_f two thigh-weighted apex verts crossed it, were classified as
armhole rim and teleported ~36 cm up to z_arm — QA showed them as sliver
spikes off the seat in deep Crouch_Fwd (worst vert-from-centroid 294 mm)."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
snap_eps = 0.006 * lm.scale
leg_z_max = 0.5 * (lm.thigh_z + min(lm.z_arm, lm.scoop_back))
counts = {"leg": 0, "scoop": 0, "armhole": 0, "strap": 0}
boundary = set()
for e in bm.edges:
if len(e.link_faces) == 1:
boundary.update(v.index for v in e.verts)
for i in boundary:
v = bm.verts[i]
x, y, z = v.co.x, v.co.y, v.co.z
ax = abs(x)
if z < leg_z_max:
v.co.z = float(lm.z_leg(x, y))
counts["leg"] += 1
elif ax < lm.sx0 - snap_eps:
v.co.z = float(lm.scoop(y))
counts["scoop"] += 1
elif ax > lm.sx1 + snap_eps:
v.co.z = lm.z_arm
counts["armhole"] += 1
else:
edge = lm.sx0 if abs(ax - lm.sx0) <= abs(ax - lm.sx1) else lm.sx1
v.co.x = edge if x >= 0.0 else -edge
counts["strap"] += 1
bm.to_mesh(me)
bm.free()
me.update()
log("flattened rims: " + " ".join(f"{k}={v}" for k, v in counts.items()))
# --------------------------------------------------------------------------
# Region classification (per texel — interpolated position + normal)
# --------------------------------------------------------------------------
def classify_texels(pos, nrm, lm):
"""Classify N texels. pos/nrm are (N,3) body-local arrays. Returns (N,)
uint8 label ids. Precedence: trim (straps + opening edges), block, body."""
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
ax = np.abs(x)
tw = lm.trim_w
scoop = lm.scoop(y)
zl = lm.z_leg(x, y)
strap = (ax >= lm.sx0 - 0.5 * tw) & (ax <= lm.sx1 + 0.5 * tw) \
& (z >= scoop - tw)
scoop_trim = (ax < lm.sx0) & (z >= scoop - tw)
arm_trim = (ax > lm.sx1) & (z >= lm.z_arm - tw)
leg_trim = z <= zl + tw
trim = strap | scoop_trim | arm_trim | leg_trim
block = (np.abs(nrm[:, 0]) >= BLOCK_NX_MIN) & (z <= lm.z_arm - tw)
lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8)
lab[block] = LABEL_ID["block"]
lab[trim] = LABEL_ID["trim"]
return lab
def bake_mask_and_albedo(shell, lm, mask_path, albedo_name, seed):
"""One pass over the faces (pre-solidify — exactly one face per texel):
bake the RGBA region mask AND the painted albedo (flat per-region
luminance + stitch lines on region boundaries + woven noise)."""
W = H = base.MASK_SIZE
mask = np.zeros((H, W, 4), dtype=np.float32)
mask[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
albedo = np.zeros((H, W, 4), dtype=np.float32)
albedo[:, :, 0:3] = ALBEDO_LUMA["body"]
albedo[:, :, 3] = 1.0
label_map = np.full((H, W), LABEL_ID["body"], dtype=np.uint8)
covered = np.zeros((H, W), dtype=bool)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
bm.normal_update()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for bake")
for face in bm.faces:
loops = face.loops[:]
uvs = [loop[uv_layer].uv for loop in loops]
pos = [loop.vert.co for loop in loops]
nrm = [loop.vert.normal for loop in loops]
for i in range(1, len(loops) - 1):
tri = (0, i, i + 1)
ys, xs, w0, w1, w2 = cov._tri_texels(
uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H)
if ys.size == 0:
continue
p = np.empty((ys.size, 3), dtype=np.float32)
n = np.empty((ys.size, 3), dtype=np.float32)
for axis in range(3):
p[:, axis] = (w0 * pos[tri[0]][axis] + w1 * pos[tri[1]][axis]
+ w2 * pos[tri[2]][axis])
n[:, axis] = (w0 * nrm[tri[0]][axis] + w1 * nrm[tri[1]][axis]
+ w2 * nrm[tri[2]][axis])
n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9)
lab = classify_texels(p, n, lm)
label_map[ys, xs] = lab
covered[ys, xs] = True
mask[ys, xs] = LABEL_RGBA_ARR[lab]
albedo[ys, xs, 0:3] = LABEL_LUMA_ARR[lab][:, None]
bm.free()
total = max(int(covered.sum()), 1)
tex_counts = np.bincount(label_map[covered], minlength=len(LABELS))
log("region texels: " + " ".join(
f"{LABELS[i]}={int(c)} ({100.0 * c / total:.1f}%)"
for i, c in enumerate(tex_counts) if c > 0))
# Woven-feel noise over the fills, before stitch lines (lines stay crisp).
rng = np.random.default_rng(seed)
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
albedo[:, :, 0:3] = np.clip(albedo[:, :, 0:3] + noise, 0.0, 1.0)
# Stitch lines: label transitions where BOTH texels belong to rasterised
# geometry (skipping UV-island borders against background).
edge = np.zeros((H, W), dtype=bool)
dh = (label_map[:, 1:] != label_map[:, :-1]) & covered[:, 1:] & covered[:, :-1]
edge[:, 1:] |= dh
edge[:, :-1] |= dh
dv = (label_map[1:, :] != label_map[:-1, :]) & covered[1:, :] & covered[:-1, :]
edge[1:, :] |= dv
edge[:-1, :] |= dv
albedo[edge, 0:3] = STITCH_LUMA
log(f"stitch lines on {int(edge.sum())} boundary texels")
# Alpha floor 2/255: keeps Godot's fix_alpha_border import pass a no-op
# (channel-packed region data, not transparency — coverall lesson).
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True)
img_mask.alpha_mode = 'CHANNEL_PACKED'
img_mask.pixels.foreach_set(mask.reshape(-1))
img_mask.update()
img_mask.filepath_raw = mask_path
img_mask.file_format = 'PNG'
img_mask.save()
log(f"baked region mask -> {mask_path}")
img_albedo = bpy.data.images.new(f"albedo_{albedo_name}", W, H, alpha=False)
img_albedo.pixels.foreach_set(albedo.reshape(-1))
img_albedo.update()
return img_albedo
# --------------------------------------------------------------------------
# Parked logo UV2 (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 purge_strays(shell, armature):
"""Drop authoring-scene leftovers before export (defensive hygiene).
Investigated for T-1089 wave 2: the 'Icosphere' seen when re-importing
any garment GLB is NOT file content — the raw glTF JSON contains exactly
one mesh (the shell). It is a bone-display widget the Blender IMPORTER
fabricates (bone_heuristic), which also leaks one such object into the
authoring scene per body-segment import session (base.clear_scene()'s
bpy.ops select_all can miss it). Exports were never polluted
(use_selection holds), but removing every object that is not the shell
or its armature keeps each per-body authoring cycle hermetic. Bone
custom_shape references are cleared for the same reason (display-only,
no glTF effect)."""
cleared = 0
for pb in armature.pose.bones:
if pb.custom_shape is not None:
pb.custom_shape = None
cleared += 1
strays = [o for o in bpy.data.objects if o not in (shell, armature)]
for o in strays:
bpy.data.objects.remove(o, do_unlink=True)
if cleared or strays:
log(f"purged {len(strays)} stray objects, cleared {cleared} bone "
f"custom shapes before export")
def author_swimsuit(body_dir, out_dir, body, offset, seed):
crotch_z = probe_crotch(body_dir)
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this
shell, armature = base.build_covered_mesh(body_dir)
weld_boundaries(shell)
lm = SuitLandmarks(armature, crotch_z)
lm.apply_ring_measurements(*measure_rings(shell, lm))
suit_cuts(shell, lm)
base.offset_outward(shell, offset)
flatten_rims(shell, lm)
# Bake + UV2 BEFORE solidify: the inner shell duplicates every face with
# the same atlas UVs but flipped normals — pre-solidify there is exactly
# one face per texel (coverall lesson; the block region is normal-gated).
author_parked_uv2(shell)
albedo_img = bake_mask_and_albedo(
shell, lm, os.path.join(out_dir, f"{body}_mask.png"), body, seed)
base.solidify(shell, base.CLOTH_THICKNESS_M)
base.assign_fabric_material(shell, albedo_img)
# Shared-name albedo before export so the GLB-embedded texture extracts
# to the <body>_base_albedo.png convention (coverall lesson).
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
purge_strays(shell, armature)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
os.path.join(out_dir, f"{body}_base_albedo.png"))
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] [--offset M]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = base.PER_BODY_OFFSET_M
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
bodies = base.BODY_TYPES
if "--bodies" in argv:
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
os.makedirs(out_dir, exist_ok=True)
log(f"swimsuit per-body mode: {len(bodies)} bodies, "
f"offset {offset * 1000:.0f} mm")
results = []
for i, body in enumerate(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_swimsuit(body_dir, out_dir, body, offset, seed=NOISE_SEED + i)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
# Runtime fallback + shared sidecars mirror the reference body.
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()
@@ -0,0 +1,565 @@
"""
blender_author_tank_top.py (T-1089 wave 2, tank_top — per-body offset shell)
Sleeveless tee authored per body via the offset-shell route. Imports
blender_author_offset_shell.py as the shared library (scene build, join,
offset, solidify, logo UV2, export), blender_author_denim_pants.py for the
required boundary-WELD practice, and blender_author_buttondown.py for the
per-pixel bake machinery (_gather_tris/_tri_cover/_interp_pos). What the tank
adds beyond the tee base, as reusable parameters:
* coverage is torso + torso_upper ONLY — no arm segments. The armhole is a
real CUT at the shoulder: verts outboard of the strap (|x| > strap_out)
and above the underarm plane are deleted, leaving a shoulder strap between
the neck scoop and the armhole.
* scoop NECKLINE cut — front scoop lower than the back, blended smoothly
across the +-y transition, both guaranteed below the jagged natural neck
ring (the segment splitter's 4.5-7.6 cm teeth) so no natural boundary
survives except the hem.
* ring-swallowing thresholds — the natural neck ring and shoulder/arm rings
are MEASURED per body (open-boundary probe after weld) and the strap /
scoop / underarm cut planes are clamped so every jagged ring vert falls in
the deleted zone. Proportional defaults derive from bone landmarks exactly
like base.derive_thresholds.
* open-rim treatment (denim practice, adapted): the hem ring is FLATTENED to
a clean plane (the ring's deepest tooth, so the hem overlaps a pants
waistband); the scoop + armhole cut edges are Laplacian-SMOOTHED along the
boundary loops into fair curves (a plane can't represent a curved scoop).
* region mask is distance-to-opening based: texels within TRIM_W of the
neckline edge -> collar band (R), within TRIM_W of an armhole edge ->
armhole trim (B), everything else -> body (G). The painted albedo shares
the same distance fields (binding bands + stitch lines + hem stitch), so
mask and albedo always agree.
* logo-capable chest UV2 — base.author_logo_uv with the chest box rescaled
(LOGO_SCALE, aspect preserved) and dropped below the front scoop so the
decal never crosses the neckline.
Region convention (spec): collar band=R (tint_0), body=G (tint_1),
armhole trim=B (tint_2). A unused. Bright default tints live in the manifest;
the albedo is a bright neutral so tints carry the colour (style pin: modern,
texture carries identity, flat toon-friendly).
Run (per-body only — offset shells author per body, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_tank_top.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/tank_top \
[--bodies average_m,child,...] [--offset 0.012] \
[--front-scoop-frac 0.28] [--strap-out-frac 0.80] [--trim-w 0.020]
Writes per body: <out_dir>/<body>.glb + <out_dir>/<body>_mask.png
<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
(per-body authoring for offset shells).
"""
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_buttondown as bdn # noqa: E402 (per-pixel bake helpers)
import blender_author_denim_pants as denim # noqa: E402 (boundary weld)
# --------------------------------------------------------------------------
# Garment parameters (average_m metres; scaled per body by bone landmarks)
# --------------------------------------------------------------------------
COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper"]
OFFSET_M = 0.012 # snug per-body standoff (Q-060 ideal)
CLOTH_THICKNESS_M = 0.004 # jersey-weight cloth, same as the tee
# Cut proportions. x fractions are of shoulder |x| (upperarm head); scoop
# drops are fractions of the spine_01->neck_01 span — the same landmark
# language as base.derive_thresholds.
STRAP_IN_FRAC = 0.54 # inner strap edge |x|
STRAP_OUT_FRAC = 0.80 # outer strap edge |x| (armhole starts here)
FRONT_SCOOP_DROP_FRAC = 0.28 # front neckline below the neck head
BACK_SCOOP_DROP_FRAC = 0.13 # back neckline below the neck head
MIN_STRAP_W_M = 0.024 # never squeeze the strap narrower than this
RING_MARGIN_M = 0.008 # clearance under/inside a measured jagged ring
SCOOP_BLEND_Y_M = 0.020 # front->back scoop height blend half-width
# Open-rim treatment.
SMOOTH_ITERS = 12 # Laplacian passes on scoop/armhole boundary loops
SMOOTH_LAM = 0.5
HEM_RING_SPAN_FRAC = 0.15 # boundary verts below spine_lo + f*span = hem ring
# Painted trim (distances measured to the opening edges, post-offset).
TRIM_W_M = 0.020 # collar / armhole binding band width
STITCH_W_M = 0.0028 # stitch line half-width
HEM_STITCH_UP_M = 0.010 # hem stitch line height above the hem plane
LOGO_SCALE = 0.85 # chest box rescale (aspect preserved)
LOGO_TOP_GAP_M = 0.020 # logo box top below the front scoop
MASK_SIZE = 512
ALBEDO_SIZE = 1024
# Bright neutral jersey — the manifest default tints carry the actual colour.
FABRIC_RGB = (0.70, 0.71, 0.73)
FABRIC_NOISE = 0.025
TRIM_MUL = 0.90 # binding bands read slightly denser than the body
STITCH_MUL = 0.55 # dark stitch lines
ALBEDO_SEED = 1094 # deterministic, distinct from other garments
def log(msg):
print(f"[tank-top] {msg}")
# --------------------------------------------------------------------------
# Landmarks + ring probe + cut parameter derivation
# --------------------------------------------------------------------------
class TankLandmarks:
"""Bone landmarks the proportional parameters scale from."""
def __init__(self, armature):
bones = armature.data.bones
ua_l = bones.get("upperarm_l")
ua_r = bones.get("upperarm_r")
neck = bones.get("neck_01")
spine01 = bones.get("spine_01")
if not all([ua_l, ua_r, neck, spine01]):
raise RuntimeError("landmark bones missing — not the 65-bone rig?")
self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
self.neck_z = neck.head_local.z
self.spine_lo = spine01.head_local.z
self.span = self.neck_z - self.spine_lo
self.scale = self.shoulder_x / base._REF_SHOULDER_X
log(f"landmarks: shoulder_x={self.shoulder_x:.4f} neck_z={self.neck_z:.4f} "
f"spine_lo={self.spine_lo:.4f} span={self.span:.4f} scale={self.scale:.3f}")
def probe_rings(shell, lm):
"""Measure the natural open-boundary rings of the welded torso shell.
The segment splitter cuts along weight thresholds, so all three natural
boundaries (neck ring, arm rings, hem ring) are jagged teeth. The cut
thresholds below are clamped so the neck + arm rings fall entirely inside
the deleted zone; the hem ring is flattened to a plane instead.
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
bverts = set()
for e in bm.edges:
if len(e.link_faces) == 1:
bverts.update(v.index for v in e.verts)
hem_test_z = lm.spine_lo + HEM_RING_SPAN_FRAC * lm.span
hem, neck, arm = [], [], []
for i in bverts:
co = bm.verts[i].co
if co.z < hem_test_z:
hem.append(co.copy())
elif abs(co.x) < 0.5 * lm.shoulder_x:
neck.append(co.copy())
else:
arm.append(co.copy())
bm.free()
if not hem or not neck or not arm:
raise RuntimeError(
f"ring probe incomplete: hem={len(hem)} neck={len(neck)} arm={len(arm)}")
rings = {
"hem_min_z": min(c.z for c in hem),
"hem_max_z": max(c.z for c in hem),
"hem_test_z": hem_test_z,
"neck_max_ax": max(abs(c.x) for c in neck),
"neck_min_z": min(c.z for c in neck),
"arm_min_ax": min(abs(c.x) for c in arm),
"arm_min_z": min(c.z for c in arm),
}
log(f"rings: hem n={len(hem)} z {rings['hem_min_z']:.3f}..{rings['hem_max_z']:.3f}; "
f"neck n={len(neck)} |x|<={rings['neck_max_ax']:.3f} z>={rings['neck_min_z']:.3f}; "
f"arm n={len(arm)} |x|>={rings['arm_min_ax']:.3f} z>={rings['arm_min_z']:.3f}")
return rings
def derive_cut(lm, rings):
"""Cut planes from proportional defaults, clamped to swallow the rings."""
strap_out = min(STRAP_OUT_FRAC * lm.shoulder_x,
rings["arm_min_ax"] - RING_MARGIN_M)
strap_in = max(STRAP_IN_FRAC * lm.shoulder_x,
rings["neck_max_ax"] + RING_MARGIN_M)
min_w = MIN_STRAP_W_M * lm.scale
if strap_out - strap_in < min_w:
squeezed = strap_out - min_w
floor = rings["neck_max_ax"] + 0.004
if squeezed < floor:
log(f"WARNING: strap squeezed against the neck ring "
f"(in={squeezed:.3f} floor={floor:.3f}) — using floor")
squeezed = floor
strap_in = squeezed
back_scoop = min(lm.neck_z - BACK_SCOOP_DROP_FRAC * lm.span,
rings["neck_min_z"] - RING_MARGIN_M)
front_scoop = min(lm.neck_z - FRONT_SCOOP_DROP_FRAC * lm.span, back_scoop)
armhole_z = rings["arm_min_z"] - RING_MARGIN_M
params = {
"strap_in": strap_in,
"strap_out": strap_out,
"strap_mid": 0.5 * (strap_in + strap_out),
"front_scoop": front_scoop,
"back_scoop": back_scoop,
"armhole_z": armhole_z,
"hem_plane": rings["hem_min_z"],
"hem_test_z": rings["hem_test_z"],
}
log(f"cut: strap |x| {strap_in:.3f}..{strap_out:.3f}, scoop front {front_scoop:.3f} "
f"back {back_scoop:.3f}, armhole z>{armhole_z:.3f}, hem plane {params['hem_plane']:.3f}")
return params
# --------------------------------------------------------------------------
# Geometry: tank cut + hem flatten + boundary smoothing
# --------------------------------------------------------------------------
def _scoop_z(y, params):
"""Neckline height at this y — front scoop blended into the back scoop."""
t = (y * base.FRONT_Y_SIGN) / SCOOP_BLEND_Y_M * 0.5 + 0.5
t = min(max(t, 0.0), 1.0)
return params["back_scoop"] + (params["front_scoop"] - params["back_scoop"]) * t
def tank_cut(shell, params):
"""Delete the neck scoop and the armholes.
Neck zone: |x| < strap_in AND z above the (front/back blended) scoop.
Armhole zone: |x| > strap_out AND z above the underarm plane.
Both thresholds were clamped so the jagged natural neck/arm rings fall
entirely inside the deleted zone — the only natural boundary that
survives is the hem ring.
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
doomed = []
for v in bm.verts:
x, y, z = v.co.x, v.co.y, v.co.z
ax = abs(x)
if ax < params["strap_in"] and z > _scoop_z(y, params):
doomed.append(v)
elif ax > params["strap_out"] and z > params["armhole_z"]:
doomed.append(v)
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"tank cut removed {len(doomed)} verts; {len(shell.data.vertices)} remain")
def flatten_hem(shell, params):
"""Pull the hem ring's jagged teeth onto one clean plane (denim practice).
The plane sits at the ring's DEEPEST tooth, so the straightened hem keeps
overlapping a pants waistband instead of retreating to the shallowest
notch (a tank tucks over the waist; extra length is correct here).
"""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
hem_idx = set()
for e in bm.edges:
if len(e.link_faces) == 1:
for v in e.verts:
if v.co.z < params["hem_test_z"]:
hem_idx.add(v.index)
for i in hem_idx:
bm.verts[i].co.z = params["hem_plane"]
bm.to_mesh(me)
bm.free()
me.update()
log(f"flattened hem ring: {len(hem_idx)} verts -> z={params['hem_plane']:.3f}")
def smooth_open_rims(shell, params, iters=None, lam=SMOOTH_LAM):
"""Laplacian-relax the scoop + armhole boundary loops into fair curves.
Vertex-deletion cuts leave sawtooth edges at mesh resolution; a plane
flatten (denim) can't express a curved scoop, so each boundary vert is
repeatedly pulled toward the midpoint of its two loop neighbours. Hem
verts (already on their plane) are pinned; interior verts never move, so
weights/UVs are untouched.
"""
iters = SMOOTH_ITERS if iters is None else iters
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
nbr = {}
for e in bm.edges:
if len(e.link_faces) == 1:
a, b = e.verts
nbr.setdefault(a.index, []).append(b.index)
nbr.setdefault(b.index, []).append(a.index)
hem_lim = params["hem_plane"] + 0.001
movable = [i for i, ns in nbr.items()
if len(ns) == 2 and bm.verts[i].co.z > hem_lim]
for _ in range(iters):
moved = {}
for i in movable:
n1, n2 = nbr[i]
mid = (bm.verts[n1].co + bm.verts[n2].co) * 0.5
moved[i] = bm.verts[i].co.lerp(mid, lam)
for i, co in moved.items():
bm.verts[i].co = co
bm.to_mesh(me)
bm.free()
me.update()
log(f"smoothed {len(movable)} scoop/armhole rim verts ({iters} passes)")
def collect_trim_edges(shell, params):
"""Opening-edge vert positions (post-offset) for the distance-based trim.
Returns (neck_pts, arm_pts) float32 arrays. Hem verts are excluded — the
hem gets a painted stitch line, not a tinted band (spec: 3 regions).
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
neck, arm = [], []
seen = set()
for e in bm.edges:
if len(e.link_faces) != 1:
continue
for v in e.verts:
if v.index in seen:
continue
seen.add(v.index)
if v.co.z < params["hem_test_z"]:
continue
if abs(v.co.x) < params["strap_mid"]:
neck.append(v.co[:])
else:
arm.append(v.co[:])
bm.free()
log(f"trim edges: neckline {len(neck)} verts, armholes {len(arm)} verts")
return (np.array(neck, dtype=np.float32),
np.array(arm, dtype=np.float32))
# --------------------------------------------------------------------------
# Per-pixel bake: region mask + painted albedo share the distance fields
# --------------------------------------------------------------------------
def _min_dist(pos, pts):
"""Min euclidean distance from each row of pos (N,3) to the set pts (K,3)."""
if pts.size == 0:
return np.full(pos.shape[0], np.inf, dtype=np.float32)
d2 = ((pos[:, None, :] - pts[None, :, :]) ** 2).sum(axis=2)
return np.sqrt(d2.min(axis=1))
def _classify_px(dn, da, trim_w):
"""One-hot RGBA rows: collar band R / armhole trim B / body G."""
n = dn.shape[0]
rgba = np.zeros((n, 4), dtype=np.float32)
is_r = (dn < trim_w) & (dn <= da)
is_b = (da < trim_w) & (da < dn)
rgba[:, 1] = 1.0
rgba[is_r] = (1.0, 0.0, 0.0, 0.0)
rgba[is_b] = (0.0, 0.0, 1.0, 0.0)
return rgba
def _paint_px(pos, rows, dn, da, dims):
"""Painted albedo: binding bands, band stitch lines, hem stitch (in-place)."""
mul = np.ones(pos.shape[0], dtype=np.float32)
d = np.minimum(dn, da)
mul[d < dims["trim_w"]] = TRIM_MUL
mul[np.abs(d - dims["trim_w"]) < dims["stitch_w"]] = STITCH_MUL
hem_line = np.abs(pos[:, 2] - dims["hem_stitch_z"]) < dims["stitch_w"]
mul[hem_line] = STITCH_MUL
out = rows * mul[:, None]
np.clip(out, 0.0, 1.0, out)
return out
def bake_tank_maps(shell, lm, params, neck_pts, arm_pts, mask_path, albedo_path):
"""One pass over UV0: bake <body>_mask.png + <body>_base_albedo.png."""
dims = {
"trim_w": TRIM_W_M * lm.scale,
"stitch_w": STITCH_W_M * lm.scale,
"hem_stitch_z": params["hem_plane"] + HEM_STITCH_UP_M * lm.scale,
}
tris = bdn._gather_tris(shell)
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
mbuf[:, :, 1] = 1.0 # green background = body (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)
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
cover = bdn._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
if cover is not None:
pos = bdn._interp_pos(cover, co_a, co_b, co_c)
dn = _min_dist(pos, neck_pts)
da = _min_dist(pos, arm_pts)
mbuf[cover[0], cover[1], :] = _classify_px(dn, da, dims["trim_w"])
cover = bdn._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
if cover is not None:
pos = bdn._interp_pos(cover, co_a, co_b, co_c)
dn = _min_dist(pos, neck_pts)
da = _min_dist(pos, arm_pts)
abuf[cover[0], cover[1], :] = _paint_px(
pos, abuf[cover[0], cover[1], :], dn, da, dims)
tot = MASK_SIZE * MASK_SIZE
log("mask texels: collar={:.1f}% body={:.1f}% armhole={:.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))
def _save(buf, name, path, alpha):
arr = buf
if not alpha:
arr = np.concatenate(
[buf, np.ones(buf.shape[:2] + (1,), dtype=np.float32)], axis=2)
img = bpy.data.images.new(name, buf.shape[1], buf.shape[0], alpha=alpha)
img.pixels.foreach_set(arr.reshape(-1))
img.update()
img.filepath_raw = path
img.file_format = 'PNG'
img.save()
return img
_save(mbuf, "tank_region_mask", mask_path, alpha=True)
log(f"baked region mask -> {mask_path}")
albedo_img = _save(abuf, "tank_base_albedo", albedo_path, alpha=False)
log(f"saved painted albedo -> {albedo_path}")
return albedo_img
# --------------------------------------------------------------------------
# Author one body
# --------------------------------------------------------------------------
def author_tank(body_dir, out_dir, body, offset):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
lm = TankLandmarks(armature)
denim.weld_boundaries(shell) # required practice: weld seam rings
rings = probe_rings(shell, lm)
params = derive_cut(lm, rings)
tank_cut(shell, params)
flatten_hem(shell, params)
smooth_open_rims(shell, params)
base.offset_outward(shell, offset)
neck_pts, arm_pts = collect_trim_edges(shell, params)
base.solidify(shell, CLOTH_THICKNESS_M)
# Logo UV2: chest box rescaled (aspect preserved) and dropped below the
# front scoop so the decal sits fully on cloth.
thr = base.derive_thresholds(armature)
half_w = (thr["chest_x"][1] - thr["chest_x"][0]) * LOGO_SCALE / 2.0
box_h = (thr["chest_z"][1] - thr["chest_z"][0]) * LOGO_SCALE
top = params["front_scoop"] - LOGO_TOP_GAP_M * lm.scale
thr_logo = dict(thr)
thr_logo["chest_x"] = (-half_w, half_w)
thr_logo["chest_z"] = (top - box_h, top)
base.author_logo_uv(shell, thr_logo)
mask_path = os.path.join(out_dir, f"{body}_mask.png")
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
albedo_img = bake_tank_maps(shell, lm, params, neck_pts, arm_pts,
mask_path, albedo_path)
base.assign_fabric_material(shell, albedo_img)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def main():
global FRONT_SCOOP_DROP_FRAC, STRAP_OUT_FRAC, TRIM_W_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] "
"[--front-scoop-frac F] [--strap-out-frac F] [--trim-w 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 "--front-scoop-frac" in argv:
FRONT_SCOOP_DROP_FRAC = float(argv[argv.index("--front-scoop-frac") + 1])
if "--strap-out-frac" in argv:
STRAP_OUT_FRAC = float(argv[argv.index("--strap-out-frac") + 1])
if "--trim-w" in argv:
TRIM_W_M = float(argv[argv.index("--trim-w") + 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 mode: {len(bodies)} bodies, offset {offset * 1000:.1f} mm, "
f"front scoop {FRONT_SCOOP_DROP_FRAC}, strap out {STRAP_OUT_FRAC}")
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_tank(body_dir, out_dir, body, offset)
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}"))
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()
@@ -0,0 +1,654 @@
"""
blender_author_track_jacket.py (T-1089 wave 2 — track_jacket, sport family)
Authors the TRACK JACKET as per-body offset shells, reusing
blender_author_offset_shell.py as a library (scene build, segment join,
bone-ratio thresholds, offset, solidify, logo UV2, skinned GLB export).
Sport-top sibling of blender_author_outerwear.py (jacket_modern); what track
kit needs beyond that script is delivered as reusable parameters, not hacks:
* SLEEVE STRIPES AS THEIR OWN REGION (the recolorable team stripe): two
parallel stripes run along the TOP of each sleeve from the shoulder seam
to the cuff. Stripe placement uses a per-|x| arm-axis interpolation from
the upperarm/lowerarm bone landmarks (the denim script's per-z leg-axis
technique rotated onto the arms), with constant-metric arc widths, so the
stripes stay parallel on every body.
* Region layout per spec: collar=R, body=G, sleeve stripes=B,
cuffs + hem band=A (toon_garment.gdshader channel-blends 4 tints).
* TEXEL-level combined bake (denim best practice): every UV0 triangle is
rasterized once with barycentric-interpolated 3D positions and ONE
analytic feature-field evaluation drives BOTH the painted albedo and the
region mask, so paint and regions always agree.
* Painted albedo identity (luma-carried, survives any tint): full front
ZIP (bright teeth dashes + darker placket + edge stitching) running hem
-> through the stand collar; STAND COLLAR raised look via texture
shading (base seam ring + cast-shadow band under the collar + brighter
gradient toward the collar rim — geometry stays stock); ribbed knit cuff
+ hem bands with border stitch lines; stripe edge piping.
* Required bottoms/footwear practices adopted for this top
(blender_author_denim_pants.py): boundary WELD of coincident
segment-seam rings before offsetting (elbow/shoulder/chest seams offset
as one surface, no gap rings) and open-rim FLATTENING — the segment
splitter leaves ~4.5-7.6 cm jagged teeth at the torso-bottom hem ring,
and the vert-threshold wrist cut leaves jagged sleeve ends. The hem ring
is pulled UP to its own valley (clean elastic hem, no invented
coverage) with a post-solidify residue clamp; the wrist rings are pulled
OUT to the nominal cut plane (clean cuff edge over real forearm skin).
* LEFT-BREAST logo patch (logo-capable): the base UV2 chest box shifts
off-centre — the zip owns the centre line — using the outerwear ratios.
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_track_jacket.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/track_jacket \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-keep 0.90]
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 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)
def log(msg):
print(f"[track-jacket] {msg}")
# --------------------------------------------------------------------------
# Parameters — track_jacket. A sport-top variant should only touch this block.
# --------------------------------------------------------------------------
GARMENT_ID = "track_jacket"
SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
]
OFFSET_M = 0.018 # sport shell drape: between the 16 mm hoodie and the
# 22 mm casual jacket (per-body construction
# guarantees body clearance either way)
CLOTH_THICKNESS_M = 0.005 # tricot/poly shell weight
WRIST_KEEP_FRAC = 0.90 # fraction of the lowerarm kept (hands show)
CUFF_BAND_FRAC = 0.18 # last fraction of the KEPT forearm = knit cuff (A)
WELD_DIST = 5e-4 # boundary-ring weld tolerance (0.5 mm)
TEX_SIZE = 1024 # albedo + mask resolution (zip teeth need > 512)
# Stand collar (outerwear-calibrated: taller + wider than the tee band).
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below the neck head
COLLAR_X_MULT = 1.15 # slightly wider than the tee band
# Sleeve stripes — metric widths on average_m, scaled by shoulder ratio.
STRIPE_GAP_HALF_M = 0.006 # half-gap between the two stripes (about the top line)
STRIPE_W_M = 0.018 # width of each stripe
STRIPE_EDGE_W_M = 0.0035 # painted piping line at each stripe border
# Front zip (hem -> through the collar), slimmer than jacket_modern's placket.
ZIP_HALF_FRAC = 0.022 / base._REF_SHOULDER_X # placket half-width / shoulder |x|
TEETH_HALF_W = 0.006 # zip teeth strip half-width, metres
TEETH_PERIOD = 0.024 # dash period along Z, metres
TEETH_DUTY = 0.014 # bright dash length within a period, metres
# Hem band height as fraction of the garment span (collar_z_min - hem plane).
HEM_BAND_FRAC = 0.07
RIB_PERIOD_M = 0.009 # knit rib period on cuff/hem bands
LINE_W_M = 0.004 # seam / border stitch line half-width
# Collar raised-look shading.
SHADOW_H_FRAC = 0.30 # under-collar cast-shadow band height, x neck_len
COLLAR_GRAD = 0.06 # extra luma toward the collar rim (raised read)
COLLAR_REACH_MULT = 1.35 # seam/shadow lateral reach, x collar_x_abs
# Left-breast logo patch (outerwear ratios; centre line belongs to the zip).
CHEST_X_LO_FRAC = 0.035 / base._REF_SHOULDER_X
CHEST_X_HI_FRAC = 0.115 / base._REF_SHOULDER_X
CHEST_Z_LO_FRAC = (1.30 - base._REF_SPINE_LO_Z) / (base._REF_NECK_Z - base._REF_SPINE_LO_Z)
CHEST_Z_HI_FRAC = (1.42 - base._REF_SPINE_LO_Z) / (base._REF_NECK_Z - base._REF_SPINE_LO_Z)
# Painted-albedo luma palette (toon_garment recolors by luma: tint*(luma*1.5+0.2)).
BASE_LUMA = 0.55 # tricot shell
STRIPE_LUMA = 0.62 # stripes slightly lighter (read under same-tint too)
STRIPE_EDGE_LUMA = 0.36 # stripe piping
COLLAR_LUMA = 0.57 # stand collar band
COLLAR_SEAM_LUMA = 0.30 # collar base seam ring
COLLAR_SHADOW_LUMA = 0.42 # cast shadow just under the collar base
PLACKET_LUMA = 0.48 # zip placket band
ZIP_EDGE_LUMA = 0.34 # placket edge stitching
TEETH_LUMA = 0.88 # bright zip teeth dashes
TEETH_GAP_LUMA = 0.34 # dark tape between dashes
BAND_LUMA = 0.46 # ribbed cuff + hem bands
BAND_LINE_LUMA = 0.32 # band border stitch line
RIB_DELTA = 0.035 # knit rib darkening within bands
ALBEDO_NOISE = 0.02 # woven jitter
FABRIC_HUE = np.array([0.97, 0.98, 1.03], dtype=np.float32) # faint cool cast
NOISE_SEED = 2094
# --------------------------------------------------------------------------
# Thresholds — base derivation + track-jacket extras from the same armature
# --------------------------------------------------------------------------
def track_thresholds(armature, wrist_keep):
thr = base.derive_thresholds(armature)
bones = armature.data.bones
neck = bones.get("neck_01")
ua_l = bones.get("upperarm_l")
ua_r = bones.get("upperarm_r")
la_l = bones.get("lowerarm_l")
spine01 = bones.get("spine_01")
if ua_l and ua_r:
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
else:
shoulder_x = base._REF_SHOULDER_X
thr["sh"] = shoulder_x / base._REF_SHOULDER_X
# Taller, wider stand collar.
if neck:
neck_len = neck.tail_local.z - neck.head_local.z
thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
thr["neck_len"] = neck_len
else:
thr["neck_len"] = base._REF_NECK_LEN
thr["collar_x_abs"] = thr["collar_x_abs"] * COLLAR_X_MULT
# Front zip placket.
thr["zip_half_x"] = shoulder_x * ZIP_HALF_FRAC
# Wrist cut planes + knit cuff start on the lowerarm bones.
cut_planes = []
cuff_abs = []
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
b = bones.get(bone_name)
if b is None:
log(f"WARNING: bone {bone_name} missing — sleeve left full-length")
continue
head_x = b.head_local.x
tail_x = b.tail_local.x
cut_x = head_x + wrist_keep * (tail_x - head_x)
band_x = head_x + (wrist_keep - CUFF_BAND_FRAC) * (tail_x - head_x)
cut_planes.append((sign, cut_x))
cuff_abs.append(abs(band_x))
log(f"wrist cut {bone_name}: keep |x| up to {cut_x:.3f} "
f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {band_x:.3f}")
thr["wrist_cut_planes"] = cut_planes
thr["cuff_x_abs"] = min(cuff_abs) if cuff_abs else 1e9
# Per-|x| arm axis (shoulder -> elbow -> wrist) for stripe placement —
# the denim per-z leg-axis technique rotated onto the arms. The rig is
# x-mirrored, so the left-arm landmarks serve both sides via |x|.
if ua_l and la_l:
pts = [ua_l.head_local, la_l.head_local, la_l.tail_local]
order = np.argsort([abs(p.x) for p in pts])
thr["arm_x"] = np.array([abs(pts[i].x) for i in order])
thr["arm_y"] = np.array([pts[i].y for i in order])
thr["arm_z"] = np.array([pts[i].z for i in order])
else:
log("WARNING: arm landmark bones missing — stripes disabled")
thr["arm_x"] = None
# Left-breast logo patch (replaces the base full-chest box).
thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC)
if neck and spine01:
spine_lo = spine01.head_local.z
span = neck.head_local.z - spine_lo
thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span,
spine_lo + CHEST_Z_HI_FRAC * span)
log(f"track thresholds: collar z>={thr['collar_z_min']:.3f} "
f"|x|<{thr['collar_x_abs']:.3f} zip half {thr['zip_half_x']:.3f} "
f"cuff |x|>={thr['cuff_x_abs']:.3f} sh {thr['sh']:.3f} "
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
return thr
# --------------------------------------------------------------------------
# Geometry: weld + wrist cut + open-rim flattening (denim required practices)
# --------------------------------------------------------------------------
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 wrist_cut(shell, thr):
"""Delete forearm verts beyond the wrist plane on each side."""
cut_planes = thr.get("wrist_cut_planes", [])
if not cut_planes:
log("WARNING: no wrist cut planes — sleeves stay full length")
return
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = []
for v in bm.verts:
for sign, thr_x in cut_planes:
if sign > 0 and v.co.x > thr_x:
to_delete.append(v)
break
if sign < 0 and v.co.x < thr_x:
to_delete.append(v)
break
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"wrist cut removed {len(to_delete)} verts; "
f"{len(shell.data.vertices)} remain")
def flatten_open_rims(shell, thr):
"""Pull the open boundary rings onto clean planes (pre-offset).
The segment splitter cuts along weight thresholds, so the torso-bottom
hem ring is a jagged ring of teeth (the denim script measured ~4.5-7.6 cm
across bodies), and the vert-threshold wrist cut leaves jagged sleeve
ends. Three rims are cleaned:
* WRIST rings (|x| >= sleeve_x_abs, per side): pulled OUT to the
nominal bone-plane cut — a clean vertical cuff edge; the forearm
skin continues well past the plane, so no clip risk is invented.
* HEM ring (bottom of the remaining boundary): pulled UP to its own
valley (the shallowest notch) — a clean straight elastic hem without
inventing coverage below the authored shell.
* NECK ring (top): left natural — every wave-1 top ships the natural
neckline boundary and the collar band owns that edge visually.
Only boundary verts move; weights and loop UVs ride along. Returns the
hem plane z (needed for the hem band paint + post-solidify clamp).
"""
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)
sleeve_x = thr["sleeve_x_abs"]
cut_by_sign = {sign: cut_x for sign, cut_x in thr.get("wrist_cut_planes", [])}
wrist = [i for i in boundary if abs(bm.verts[i].co.x) >= sleeve_x]
torso = [i for i in boundary if abs(bm.verts[i].co.x) < sleeve_x]
zs = [bm.verts[i].co.z for i in torso]
z_mid = (min(zs) + max(zs)) / 2.0
hem = [i for i in torso if bm.verts[i].co.z <= z_mid]
neck = [i for i in torso if bm.verts[i].co.z > z_mid]
moved_wrist = 0
for i in wrist:
v = bm.verts[i]
sign = 1 if v.co.x >= 0.0 else -1
if sign in cut_by_sign:
v.co.x = cut_by_sign[sign]
moved_wrist += 1
hem_lo = min(bm.verts[i].co.z for i in hem)
hem_plane = max(bm.verts[i].co.z for i in hem) # the valley (shallowest notch)
for i in hem:
bm.verts[i].co.z = hem_plane
bm.to_mesh(me)
bm.free()
me.update()
log(f"flattened open rims: {moved_wrist} wrist verts -> nominal cut planes; "
f"hem ring ({len(hem)} verts, teeth {hem_plane - hem_lo:.3f} m) -> "
f"z={hem_plane:.3f}; neck ring left natural ({len(neck)} verts)")
return hem_plane
def clamp_hem_residue(shell, hem_plane):
"""Post-solidify safety clamp: interior tooth verts still below the hem
plane (multi-triangle teeth) get squashed onto it."""
me = shell.data
n = 0
for v in me.vertices:
if v.co.z < hem_plane:
v.co.z = hem_plane
n += 1
me.update()
if n:
log(f"clamped {n} residual hem verts -> {hem_plane:.3f}")
# --------------------------------------------------------------------------
# Track feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _paint_texels(X, Y, Z, noise, thr, hem_plane, hem_top):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions.
ONE feature evaluation drives both outputs (denim practice), so the
painted albedo and the region mask always agree.
Region priority: cuff/hem band (A) > sleeve stripes (B) > collar (R) > body (G).
"""
n = X.shape[0]
ax = np.abs(X)
front = Y * base.FRONT_Y_SIGN > 0.004
sh = thr["sh"]
on_sleeve = ax >= thr["sleeve_x_abs"]
in_cuff = ax >= thr["cuff_x_abs"]
in_hem = (~on_sleeve) & (Z <= hem_top)
czmin = thr["collar_z_min"]
in_collar = (~on_sleeve) & (Z >= czmin) & (ax < thr["collar_x_abs"])
# Sleeve stripes: arc distance from the top line of the per-|x| arm axis.
if thr.get("arm_x") is not None:
cy = np.interp(ax, thr["arm_x"], thr["arm_y"])
cz = np.interp(ax, thr["arm_x"], thr["arm_z"])
dy = Y - cy
dz = Z - cz
r = np.hypot(dy, dz) + 1e-9
arc = np.arctan2(np.abs(dy), dz) * r # 0 on the sleeve top line
g0 = STRIPE_GAP_HALF_M * sh
w = STRIPE_W_M * sh
stripe_zone = on_sleeve & ~in_cuff
stripe = stripe_zone & (arc >= g0) & (arc <= g0 + w)
ew = STRIPE_EDGE_W_M * sh
stripe_edge = stripe_zone & (
(np.abs(arc - g0) < ew) | (np.abs(arc - (g0 + w)) < ew))
else:
arc = np.zeros(n, dtype=np.float32)
stripe = np.zeros(n, dtype=bool)
stripe_edge = np.zeros(n, dtype=bool)
# --- region mask (collar=R, body=G, stripes=B, cuffs/hem=A) -------------
mask = np.zeros((n, 4), dtype=np.float32)
is_a = in_cuff | in_hem
is_b = stripe & ~is_a
is_r = in_collar & ~is_a & ~is_b
is_g = ~(is_a | is_b | is_r)
mask[is_a, 3] = 1.0
mask[is_b, 2] = 1.0
mask[is_r, 0] = 1.0
mask[is_g, 1] = 1.0
# --- painted luma detail -------------------------------------------------
v = np.full(n, BASE_LUMA, dtype=np.float32)
lw = LINE_W_M * sh
# Stripes + piping.
v[stripe] = STRIPE_LUMA
v[stripe_edge] = STRIPE_EDGE_LUMA
# Stand collar raised look: band luma + brighter gradient toward the rim,
# cast-shadow band + seam ring at the collar base (texture-carried depth).
reach = thr["collar_x_abs"] * COLLAR_REACH_MULT
v[in_collar] = COLLAR_LUMA
grad = np.clip((Z - czmin) / max(thr["neck_len"], 1e-6), 0.0, 1.0)
v[in_collar] += COLLAR_GRAD * grad[in_collar]
shadow_h = SHADOW_H_FRAC * thr["neck_len"]
under = (~in_collar) & (~on_sleeve) & (ax < reach) \
& (Z < czmin) & (Z >= czmin - shadow_h)
t = np.clip((czmin - Z) / max(shadow_h, 1e-6), 0.0, 1.0)
v[under] = COLLAR_SHADOW_LUMA \
+ (BASE_LUMA - COLLAR_SHADOW_LUMA) * t[under]
seam = (~on_sleeve) & (np.abs(Z - czmin) < lw) & (ax < reach)
v[seam] = COLLAR_SEAM_LUMA
# Ribbed cuff + hem bands: band luma, knit ribs, border stitch lines.
band = in_cuff | in_hem
v[band] = BAND_LUMA
cuff_rib = in_cuff & (np.mod(arc, RIB_PERIOD_M) < RIB_PERIOD_M * 0.5)
hem_rib = in_hem & (np.mod(X, RIB_PERIOD_M) < RIB_PERIOD_M * 0.5)
v[cuff_rib | hem_rib] -= RIB_DELTA
v[np.abs(ax - thr["cuff_x_abs"]) < lw] = BAND_LINE_LUMA
v[(~on_sleeve) & (np.abs(Z - hem_top) < lw)] = BAND_LINE_LUMA
# Front zip: placket band + edge stitching + dashed teeth, hem -> through
# the collar (paint overrides bands/collar luma; regions stay untouched).
zh = thr["zip_half_x"]
v[front & (ax < zh)] = PLACKET_LUMA
v[front & (np.abs(ax - zh) < lw * 0.7)] = ZIP_EDGE_LUMA
teeth = front & (ax < TEETH_HALF_W)
dash = np.mod(Z, TEETH_PERIOD) < TEETH_DUTY
v[teeth & dash] = TEETH_LUMA
v[teeth & ~dash] = TEETH_GAP_LUMA
# --- albedo --------------------------------------------------------------
lum = np.clip(v + noise, 0.0, 1.0)
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = np.clip(lum * FABRIC_HUE[c], 0.0, 1.0)
alb[:, 3] = 1.0
return alb, mask
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, thr,
hem_plane, hem_top, W, H):
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
evaluate the track feature 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],
thr, hem_plane, hem_top)
alb_buf[ysin, xsin] = alb
mask_buf[ysin, xsin] = mask
def paint_albedo_and_mask(shell, thr, hem_plane, hem_top, out_dir, 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] = np.clip(
(BASE_LUMA + noise_buf) * FABRIC_HUE[c], 0.0, 1.0)
alb_buf[:, :, 3] = 1.0
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
mask_buf[:, :, 1] = 1.0 # background = body 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]),
thr, hem_plane, hem_top, W, H)
tri_count += 1
bm.free()
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
# Honest region tally over the whole mask (texel counts, background excl.
# impossible — background is body green by design).
tot = W * H
counts = {
"collar(R)": int((mask_buf[:, :, 0] > 0.5).sum()),
"body(G)": int((mask_buf[:, :, 1] > 0.5).sum()),
"stripes(B)": int((mask_buf[:, :, 2] > 0.5).sum()),
"cuff/hem(A)": int((mask_buf[:, :, 3] > 0.5).sum()),
}
log("mask texels: " + " ".join(
f"{k}={v} ({100.0 * v / tot:.1f}%)" for k, v in counts.items()))
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
_save(mask_buf, f"{GARMENT_ID}_mask_{body}",
os.path.join(out_dir, f"{body}_mask.png"))
albedo_img = _save(alb_buf, f"{GARMENT_ID}_albedo_{body}",
os.path.join(out_dir, f"{body}_base_albedo.png"))
log(f"saved mask -> {body}_mask.png")
log(f"saved albedo -> {body}_base_albedo.png")
# Repoint the image at the shared sidecar name before export: the glTF
# exporter names the embedded image after the file basename, and Godot
# extracts it as <glb>_<imagename>.png -> <body>_base_albedo.png (tshirt
# convention). The shared file is re-pointed to average_m's at the end.
albedo_img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
albedo_img.save()
return albedo_img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_track_jacket(body_dir, out_dir, body, offset, wrist_keep):
base.clear_scene()
base.COVERED_SEGMENTS = SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
thr = track_thresholds(armature, wrist_keep)
weld_boundaries(shell)
wrist_cut(shell, thr)
hem_plane = flatten_open_rims(shell, thr)
base.offset_outward(shell, offset)
base.solidify(shell, CLOTH_THICKNESS_M)
clamp_hem_residue(shell, hem_plane)
hem_top = hem_plane + HEM_BAND_FRAC * (thr["collar_z_min"] - hem_plane)
log(f"hem band: z {hem_plane:.3f}..{hem_top:.3f}")
base.author_logo_uv(shell, thr) # UV2 before bake (bake reads UV0 directly)
albedo_img = paint_albedo_and_mask(shell, thr, hem_plane, hem_top,
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():
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] [--wrist-keep F]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = OFFSET_M
wrist_keep = WRIST_KEEP_FRAC
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--wrist-keep" in argv:
wrist_keep = float(argv[argv.index("--wrist-keep") + 1])
bodies = base.BODY_TYPES
if "--bodies" in argv:
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
os.makedirs(out_dir, exist_ok=True)
log(f"track-jacket per-body mode: {len(bodies)} bodies, "
f"offset {offset*1000:.0f} mm, wrist keep {wrist_keep:.2f}")
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_track_jacket(body_dir, out_dir, body, offset, wrist_keep)
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()
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "boots_modern", "slot": "feet", "covers": ["leg_lower_l", "leg_lower_r"]}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/boots_modern"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "cargo_pants", "slot": "legs"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/cargo_pants"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "joggers_modern", "slot": "legs"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/joggers_modern"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "parka_thrds", "slot": "torso"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/parka_thrds"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "shoes_formal", "slot": "feet", "covers": ["foot_l", "foot_r"]}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/shoes_formal"
}
+15
View File
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "slides", "slot": "feet", "covers": ["foot_l", "foot_r"]}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.012,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/slides"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "sneakers_modern", "slot": "feet"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/sneakers_modern"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "sweater_modern", "slot": "torso"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/sweater_modern"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "swim_trunks", "slot": "legs"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/swim_trunks"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "swimsuit_onepiece", "slot": "full_body"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/swimsuit_onepiece"
}
+15
View File
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "tank_top", "slot": "torso"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/tank_top"
}
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "track_jacket", "slot": "torso"}
],
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"clip_epsilon_m": 0.03,
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/track_jacket"
}