refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved

35 payloads move to tooling/scripts/blender/ and stay outside package scope.
They run under Blender's bundled Python, which cannot see the repo venv, so
they physically cannot import tooling.core — holding them to the D-263 contract
would either fail the gate forever or force the contract to be weakened for
everyone, and the second is how a gate stops meaning anything.

Count verified by import rather than filename: 33 import bpy/bmesh directly,
and the two that do not are still payloads per their own usage lines.
garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290.

The bash wrapper is retired rather than kept. Keeping it would have put the
install-resolution logic in two places, which is the duplication T-1286 had
just finished collapsing three copies of. domains/blender/service.py owns the
decisions — resolve_blender (native beats flatpak, ordering preserved),
resolve_payload, absolutise — and only run_payload performs. test_blender.py
pins all of them without launching Blender, which matters here more than
usual: the thing being launched is a 200 MB GUI application that writes GLBs.

`reach blender run` takes a registered payload name OR a path to any script,
because the wrapper served both — the spikes and the glb-gen skill hand it
one-off scripts of their own. An unknown name enumerates all 35 and exits 2.

The exclusion now defends itself. check_carve_out_stays_carved fails if
`scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty
exclusion proves nothing), or if an __init__.py appears there (which would make
the payloads importable — the coupling the carve-out exists to prevent). All
three arms mutation-proved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 20:55:52 +02:00
co-authored by Claude Opus 5
parent bb329e3294
commit 201dabd19b
63 changed files with 555 additions and 49 deletions
@@ -0,0 +1,615 @@
"""
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
# Convex toe box (shared base.convex_toe_box) — chunky work-boot cap.
TOE_EXT_M = 0.010 # nose extension past the longest toe (m)
TOE_WMARGIN_M = 0.006 # half-width padding (chunky)
TOE_HCLEAR_M = 0.009 # vertical headroom above the toes (flex room)
TOE_FEATHER_M = 0.020 # blend band behind the ball
# 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 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)
shaft_cut(shell, lm)
rim_z = flatten_shaft_rims(shell, lm)
# Smooth convex toe box (replaces the toe-merge + skin-conforming
# containment clamp + per-toe weight re-bind, which re-imprinted the
# individual toes and rippled under flex). The cap encloses the real skin
# toes and rebinds uniformly to the ball bone; offset then adds standoff.
base.convex_toe_box(
shell, armature,
extension=TOE_EXT_M * lm.s, width_margin=TOE_WMARGIN_M * lm.s,
height_clear=TOE_HCLEAR_M * lm.s, feather_m=TOE_FEATHER_M * lm.s)
base.offset_outward(shell, offset)
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,426 @@
"""
blender_author_buttondown.py (T-1089, buttondown_modern — per-body offset shell)
Button-down shirt authored per body via the offset-shell route. Imports
blender_author_offset_shell.py as a module and reuses its scene/build/offset/
solidify/logo-UV/export helpers; this file adds what the button-down needs
beyond the t-shirt base:
* long-sleeve coverage — torso + torso_upper + BOTH full arms (upper+lower),
with a wrist-plane cut derived from the lowerarm bone (SLEEVE_END_FRAC),
replacing the base script's upper-arm short-sleeve cut
* seam WELD after join — the body segments share exact duplicated boundary
rings (probe: 24-38 coincident verts/seam, zero near-misses), so a
remove-doubles pass gives continuous normals across segment seams ->
crack-free outward offset and no internal solidify rims (UVs live on
loops, so UV seams survive the weld; weights are identical by construction)
* per-PIXEL region mask bake (barycentric-interpolated body-local positions)
instead of the base per-face classification — the placket and cuff
boundaries cut through the middle of low-poly faces
* PAINTED albedo — texture-carried identity per the style pin: placket band
with stitch lines, button dots, collar + cuff seam lines, over the flat
toon fabric noise. Baked per body because each body archetype has its own
UV atlas (the shell inherits body UVs).
Region convention (spec): collar=R (tint_0), body=G (tint_1),
cuffs+placket=B (tint_2). A unused. Logo-capable OFF, but the UV2 chest
channel is still authored (costs nothing; enables future brand variants).
Run (per-body only — this garment ships the per-body route):
tooling/blender --background --python \
tooling/garment-fit/blender_author_buttondown.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/buttondown_modern \
[--bodies average_m,child,...] [--offset 0.009]
Writes per body: <out_dir>/<body>.glb + <out_dir>/<body>_mask.png
Plus: <out_dir>/base_albedo.png (average_m's painted albedo)
<out_dir>/reference_mask.png (copy of average_m_mask.png)
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
# --------------------------------------------------------------------------
# Garment parameters (average_m metres; scaled per body by shoulder ratio)
# --------------------------------------------------------------------------
COVERED_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.009 # slimmer standoff than the tee (12 mm) — dress shirt
CLOTH_THICKNESS_M = 0.003 # thinner cloth than the tee (4 mm)
SLEEVE_END_FRAC = 0.92 # of lowerarm bone length — long sleeve, ends above wrist
CUFF_START_FRAC = 0.62 # of lowerarm bone length — last ~38% of forearm = cuff (B)
WELD_DIST = 0.0002 # merge duplicated segment-boundary rings (0.2 mm)
MASK_SIZE = 512
ALBEDO_SIZE = 1024 # painted detail (buttons ~8 px radius) needs 1024
# Style dimensions on average_m (shoulder |x| = base._REF_SHOULDER_X);
# scaled per body by shoulder_x ratio so proportions hold from child to heavy_m.
PLACKET_HALF_M = 0.022 # placket half-width (4.4 cm total band)
STITCH_W_M = 0.0025 # stitch/seam line half-width
BUTTON_R_M = 0.0095 # button disc radius (chunky toon read)
SEAM_HALF_M = 0.0022 # collar seam line half-height
BUTTON_COUNT = 6
FRONT_MIN_Y = 0.004 # body-local front test: y * FRONT_Y_SIGN > this
# Flat toon oxford base tone + painted feature colours (luma carries through
# the luminance-preserving region tint in toon_garment.gdshader).
FABRIC_RGB = (0.63, 0.64, 0.66)
FABRIC_NOISE = 0.02
PLACKET_BAND_MUL = 1.07 # subtle lift so the band reads under any tint
STITCH_MUL = 0.52 # dark stitch lines
SEAM_MUL = 0.55 # collar/cuff seam lines
BUTTON_RGB_OUTER = (0.10, 0.10, 0.12)
BUTTON_RGB_CORE = (0.24, 0.24, 0.27)
def log(msg):
print(f"[buttondown] {msg}")
# --------------------------------------------------------------------------
# Threshold derivation (extends base.derive_thresholds with arm + style dims)
# --------------------------------------------------------------------------
def derive_arm_thresholds(armature):
"""Wrist cut planes + cuff start from the lowerarm bones (X-axis arms)."""
bones = armature.data.bones
la_l = bones.get("lowerarm_l")
la_r = bones.get("lowerarm_r")
if la_l is None or la_r is None:
raise RuntimeError("lowerarm bones missing — cannot derive sleeve cut")
def along(b, frac):
return b.head_local.x + frac * (b.tail_local.x - b.head_local.x)
cut_l = along(la_l, SLEEVE_END_FRAC) # left arm +x: delete x > cut_l
cut_r = along(la_r, SLEEVE_END_FRAC) # right arm -x: delete x < cut_r
cuff_x_abs = (abs(along(la_l, CUFF_START_FRAC))
+ abs(along(la_r, CUFF_START_FRAC))) / 2.0
log(f"sleeve: cut_l={cut_l:.3f} cut_r={cut_r:.3f} cuff |x|>={cuff_x_abs:.3f}")
return {"cut_l": cut_l, "cut_r": cut_r, "cuff_x_abs": cuff_x_abs}
def derive_style(armature):
"""Scale the painted-detail dimensions by this body's shoulder ratio."""
bones = armature.data.bones
ua_l = bones.get("upperarm_l")
ua_r = bones.get("upperarm_r")
if ua_l is None or ua_r is None:
s = 1.0
else:
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
s = shoulder_x / base._REF_SHOULDER_X
log(f"style scale {s:.3f}")
return {
"placket_half": PLACKET_HALF_M * s,
"stitch_w": STITCH_W_M * s,
"button_r": BUTTON_R_M * s,
"seam_half": SEAM_HALF_M * s,
}
def derive_buttons(shell, thr):
"""Button Z positions: evenly spaced down the placket (collar -> hem)."""
hem_z = min(v.co.z for v in shell.data.vertices)
collar_z = thr["collar_z_min"]
span = collar_z - hem_z
z_top = collar_z - 0.05 * span
z_bot = hem_z + 0.07 * span
zs = list(np.linspace(z_top, z_bot, BUTTON_COUNT))
log(f"buttons: {BUTTON_COUNT} @ z {z_bot:.3f}..{z_top:.3f} (hem {hem_z:.3f})")
return {"button_zs": zs, "hem_z": hem_z}
# --------------------------------------------------------------------------
# Geometry: seam weld + wrist cut
# --------------------------------------------------------------------------
def weld_seams(shell):
"""Merge the duplicated segment-boundary rings into one continuous shell."""
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST)
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"seam weld: {before} -> {len(shell.data.vertices)} verts "
f"({before - len(shell.data.vertices)} merged)")
def wrist_cut(shell, thr):
"""Delete sleeve verts beyond the wrist plane on each forearm."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = [v for v in bm.verts
if v.co.x > thr["cut_l"] or v.co.x < thr["cut_r"]]
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")
# --------------------------------------------------------------------------
# Per-pixel bake: region mask + painted albedo in one pass
# --------------------------------------------------------------------------
def _gather_tris(shell):
"""Fan-triangulate every face into (uv_a, uv_b, uv_c, co_a, co_b, co_c)."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
uvl = bm.loops.layers.uv[me.uv_layers[0].name] # UV0 = shared body atlas
tris = []
for face in bm.faces:
loops = face.loops[:]
uvs = [np.array((lp[uvl].uv.x, lp[uvl].uv.y)) for lp in loops]
cos = [np.array(lp.vert.co[:]) for lp in loops]
for i in range(1, len(loops) - 1):
tris.append((uvs[0], uvs[i], uvs[i + 1],
cos[0], cos[i], cos[i + 1]))
bm.free()
return tris
def _tri_cover(a, b, c, W, H):
"""Pixel centres covered by UV triangle abc -> (ys, xs, w0, w1, w2)."""
ax, ay = a[0] * (W - 1), a[1] * (H - 1)
bx, by = b[0] * (W - 1), b[1] * (H - 1)
cx, cy = c[0] * (W - 1), c[1] * (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 None
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
if abs(denom) < 1e-9:
return None # degenerate (solidify rim) — no UV area to write
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 None
return (ys[inside].ravel(), xs[inside].ravel(),
w0[inside].ravel(), w1[inside].ravel(), w2[inside].ravel())
def _interp_pos(cover, co_a, co_b, co_c):
_, _, w0, w1, w2 = cover
return (w0[:, None] * co_a[None, :]
+ w1[:, None] * co_b[None, :]
+ w2[:, None] * co_c[None, :])
def _classify_px(pos, thr):
"""Vectorized region classification -> (N,4) one-hot RGBA rows."""
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
ax = np.abs(x)
front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y
cuff = ax >= thr["cuff_x_abs"]
collar = (~cuff) & (z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"])
placket = ((~cuff) & (~collar) & front
& (ax <= thr["placket_half"]) & (z < thr["collar_z_min"]))
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) # collar band -> R
rgba[cuff | placket] = (0.0, 0.0, 1.0, 0.0) # cuffs + placket -> B
return rgba
def _paint_px(pos, rows, thr):
"""Painted albedo: placket band + stitches, seams, button dots (in-place)."""
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
ax = np.abs(x)
front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y
below_collar = z < thr["collar_z_min"]
mul = np.ones(len(x), dtype=np.float32)
band = front & below_collar & (ax <= thr["placket_half"])
mul[band] = PLACKET_BAND_MUL
stitch = front & below_collar & (np.abs(ax - thr["placket_half"])
<= thr["stitch_w"])
mul[stitch] = STITCH_MUL
collar_seam = ((np.abs(z - thr["collar_z_min"]) <= thr["seam_half"])
& (ax < thr["collar_x_abs"] * 1.8))
mul[collar_seam] = SEAM_MUL
cuff_seam = np.abs(ax - thr["cuff_x_abs"]) <= thr["stitch_w"]
mul[cuff_seam] = SEAM_MUL
out = rows * mul[:, None]
r = thr["button_r"]
for bz in thr["button_zs"]:
d2 = x * x + (z - bz) ** 2
disc = front & (d2 <= r * r)
out[disc] = BUTTON_RGB_OUTER
core = front & (d2 <= (0.45 * r) ** 2)
out[core] = BUTTON_RGB_CORE
np.clip(out, 0.0, 1.0, out)
return out
def bake_maps(shell, thr, mask_path):
"""One pass over the shell: bake <body>_mask.png + return painted albedo."""
tris = _gather_tris(shell)
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
mbuf[:, :, 1] = 1.0 # green background = main body (bilinear-bleed safe)
rng = np.random.default_rng(1090) # deterministic fabric noise
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 = _tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
if cover is not None:
pos = _interp_pos(cover, co_a, co_b, co_c)
mbuf[cover[0], cover[1], :] = _classify_px(pos, thr)
cover = _tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
if cover is not None:
pos = _interp_pos(cover, co_a, co_b, co_c)
abuf[cover[0], cover[1], :] = _paint_px(
pos, abuf[cover[0], cover[1], :], thr)
tot = MASK_SIZE * MASK_SIZE
log("mask texels: collar={:.1f}% body={:.1f}% cuff+placket={:.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("garment_region_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("base_albedo", ALBEDO_SIZE, ALBEDO_SIZE,
alpha=False)
albedo_img.pixels.foreach_set(argba.reshape(-1))
albedo_img.update()
return albedo_img
# --------------------------------------------------------------------------
# Author one body
# --------------------------------------------------------------------------
def author_buttondown(body_dir, out_dir, body, offset):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS # long-sleeve coverage set
shell, armature = base.build_covered_mesh(body_dir)
thr = base.derive_thresholds(armature) # collar/chest from bone landmarks
thr.update(derive_arm_thresholds(armature))
thr.update(derive_style(armature))
weld_seams(shell)
wrist_cut(shell, thr)
thr.update(derive_buttons(shell, thr))
base.offset_outward(shell, offset)
base.solidify(shell, CLOTH_THICKNESS_M)
base.author_logo_uv(shell, thr) # UV2 before bake (mask/albedo use UV0)
albedo_img = bake_maps(shell, thr, os.path.join(out_dir, f"{body}_mask.png"))
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
base.assign_fabric_material(shell, albedo_img)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
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 = 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"per-body mode: {len(bodies)} bodies, offset {offset*1000:.1f} mm")
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_buttondown(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}")
results.append((body, f"error: {exc}"))
# Root sidecars: reference mask + albedo mirror the reference body.
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(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png")
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,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,659 @@
"""
blender_author_denim_pants.py (T-1089, jeans_modern + denim pants family)
Authors full-length DENIM pants as per-body offset shells, reusing
blender_author_offset_shell.py as a library (scene build, join, offset,
solidify, GLB export). Sibling companions already cover the plain lower-body
family (blender_author_offset_shell_legs.py — shorts, 2-region;
blender_author_lower_shell.py — formal, crease lines); this one adds what
denim needs and they don't provide, as reusable parameters:
* TEXEL-level feature painting: every UV0 triangle is rasterized with
barycentric-interpolated 3D positions, and an analytic denim feature
field is evaluated per texel. ONE field evaluation drives BOTH outputs,
so the painted albedo and the region mask always agree:
- albedo: painted seam thread lines (outseam/inseam azimuth around the
per-z leg axis, centre-front fly, back yoke V), front pocket
arcs, back pocket outlines, belt loops, waist button,
waist/cuff border stitching — flat toon-friendly, identity
carried by the texture (style pin: modern only).
- mask: waistband -> R, legs -> G, seams + cuff band -> B
(spec: waistband=R, legs=G, seams/cuffs=B). Seam LINES are
in the B channel, not just the cuff band, so contrast-thread
recoloring works (toon_garment.gdshader channel-blends).
* boundary WELD before offsetting — the waist join (hips<->leg_upper) and
knee join (leg_upper<->leg_lower) carry duplicated boundary-ring verts
per segment; offsetting un-welded rings along diverging normals opens
cracks, so coincident verts are merged first (weights identical by
origin, so skinning is unaffected).
* --hem-frac: fraction of the hip->ankle span covered, measured up from the
ankle (1.0 = full length to the ankle = jeans; lower values give cropped
variants; cut rims are capped by Solidify(use_rim) like the base sleeves).
* a parked logo_uv TEXCOORD_1 layer (all UVs at (2,2)): pants are not
logo-capable, but toon_garment.gdshader samples UV2 unconditionally, so
every multi_region garment ships a well-defined logo channel.
* open-rim FLATTENING: the segment splitter cuts along weight thresholds,
so the waist and ankle boundary rings are jagged "teeth" (~5-6 cm deep on
average_m); boundary verts are pulled onto clean planes (waist ring down
to its own valley, ankle rings onto the ankle-joint plane) pre-offset.
* --waist-flare: extra feathered radial stand-off at the waistband rim —
deep-crouch waist-fold clip mitigation (QA evidence, peasant + jeans).
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r. Natural
boundaries give the waist opening (seg_hips top rim) and the ankle hems
(seg_leg_lower bottom) for free.
All cut/mask/paint parameters derive PER BODY from that body's own bone
landmarks (thigh_l/calf_l line) and measured mesh extents (waist rim, ankle
rim, crotch = seg_hips lowest ring), scaled by the body's garment span and
hip half-width — the same proportional-ratio philosophy as
base.derive_thresholds, so the denim details stay consistent across all 11
bodies. Per-body mode only (offset shells author per body, Q-060).
Usage (jeans_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_denim_pants.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/jeans_modern \
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 1.0] \
[--waist-flare 0.007] [--base-rgb 0.24,0.32,0.45] [--plain]
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
<out_dir>/<body>_base_albedo.png
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
<out_dir>/reference_mask.png (average_m's, runtime fallback)
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
wardrobe), Q-060 (per-body offset shells).
"""
import importlib.util
import os
import shutil
import sys
import bmesh
import bpy
import numpy as np
# --------------------------------------------------------------------------
# Import the base offset-shell module (shared machinery)
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_file_location(
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
base = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(base)
log = base.log
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
# --------------------------------------------------------------------------
# Parameters
# --------------------------------------------------------------------------
COVERED_SEGMENTS = [
"seg_hips",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
HEM_FRAC = 1.0 # 1.0 = full leg to the ankle (jeans)
WELD_DIST = 5e-4 # boundary-ring weld tolerance (0.5 mm)
TEX_SIZE = 1024 # albedo + mask resolution (painted seams need >512)
WAIST_FLARE_M = 0.007 # extra radial stand-off at the waistband rim
# (deep-crouch waist-fold mitigation, feathered)
# Vertical proportions — fractions of the garment span (waist_z - hem_z).
BAND_FRAC = 0.055 # waistband height (R region)
CUFF_FRAC = 0.032 # cuff band height (B region)
SEAM_W_FRAC = 0.0095 # painted seam line width
YOKE_DROP_FRAC = 0.055 # back yoke centre depth below the waistband
YOKE_RISE_FRAC = 0.030 # yoke V rise toward the sides
BPOCKET_TOP_FRAC = 0.075 # back pocket top below the waistband
BPOCKET_HH_FRAC = 0.048 # back pocket half-height
FPOCKET_RZ_FRAC = 0.14 # front pocket arc vertical radius
# Horizontal proportions — fractions of the hip half-width (|thigh head x|).
BPOCKET_CX_FRAC = 0.80 # back pocket centre
BPOCKET_HW_FRAC = 0.52 # back pocket half-width
FPOCKET_CX_FRAC = 1.30 # front pocket arc centre (near the outseam corner)
FPOCKET_RX_FRAC = 0.75 # front pocket arc horizontal radius
LOOP_X_FRACS = (0.55, 1.30) # belt-loop |x| positions (front + back pairs)
LOOP_W_M = 0.016 # belt loop width (m, scaled by hip width)
# Denim style (sRGB floats; saved as-is — matches the proven base pipeline).
DENIM_RGB = (0.240, 0.320, 0.450) # indigo denim
THREAD_RGB = (0.800, 0.620, 0.340) # contrast stitching thread
BUTTON_RGB = (0.850, 0.720, 0.480) # waist button metal
ALBEDO_NOISE = 0.020 # +/- woven jitter
CUFF_SHADE = 0.90 # cuff band albedo darkening
LOOP_SHADE = 0.80 # belt-loop albedo darkening
PLAIN = False # --plain: skip thread/pocket/button paint
NOISE_SEED = 2089
# Reference proportions (average_m) the fractions were calibrated against.
_REF_SPAN = 1.007 # waist rim z (1.093) - ankle z (0.086)
_REF_HIP_X = 0.0906 # |thigh_l head x|
# --------------------------------------------------------------------------
# Per-body landmarks
# --------------------------------------------------------------------------
class LegLandmarks:
"""Cut/mask/paint parameters derived from one body's bones + mesh."""
def __init__(self, armature, waist_z, ankle_z, crotch_z):
bones = armature.data.bones
thigh = bones.get("thigh_l")
calf = bones.get("calf_l")
if thigh is None or calf is None:
raise RuntimeError("thigh_l/calf_l missing — not the 65-bone rig?")
self.waist_z = waist_z
self.ankle_z = ankle_z
self.crotch_z = crotch_z
self.hip_x = abs(thigh.head_local.x)
# Leg axis control points (z-increasing) for azimuth seam placement.
# x values are the +x (left) leg; the right leg mirrors via sign.
self.leg_z_pts = np.array(
[calf.tail_local.z, calf.head_local.z, thigh.head_local.z])
self.leg_x_pts = np.array(
[abs(calf.tail_local.x), abs(calf.head_local.x),
abs(thigh.head_local.x)])
self.leg_y_pts = np.array(
[calf.tail_local.y, calf.head_local.y, thigh.head_local.y])
self.hem_z = ankle_z # finalized after the hem cut
def finalize(self, hem_z):
self.hem_z = hem_z
self.span = self.waist_z - self.hem_z
self.sh = self.hip_x / _REF_HIP_X
self.band_h = BAND_FRAC * self.span
self.cuff_h = CUFF_FRAC * self.span
self.seam_w = SEAM_W_FRAC * self.span
self.band_z = self.waist_z - self.band_h
self.cuff_top = self.hem_z + self.cuff_h
log(f"landmarks: waist={self.waist_z:.3f} hem={self.hem_z:.3f} "
f"crotch={self.crotch_z:.3f} hip_x={self.hip_x:.3f} "
f"band_z={self.band_z:.3f} cuff_top={self.cuff_top:.3f} "
f"seam_w={self.seam_w * 1000:.1f}mm")
def probe_hips_bounds(body_dir):
"""Import seg_hips alone to measure the crotch (its lowest ring) exactly."""
base.clear_scene()
objs = base.import_glb(os.path.join(body_dir, "seg_hips.glb"))
zs = []
for o in objs:
if base.is_body_mesh(o):
zs.extend(v.co.z for v in o.data.vertices)
if not zs:
raise RuntimeError("seg_hips.glb yielded no skinned mesh")
return min(zs), max(zs)
# --------------------------------------------------------------------------
# Geometry: weld + hem cut
# --------------------------------------------------------------------------
def weld_boundaries(shell):
"""Merge coincident segment-boundary verts so the offset can't open cracks."""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_DIST)
merged = before - len(bm.verts)
bm.to_mesh(me)
bm.free()
me.update()
log(f"welded segment boundaries: {merged} verts merged "
f"({before} -> {len(me.vertices)})")
def flatten_open_rims(shell, ankle_plane):
"""Pull the open boundary rings onto clean planes (pre-offset).
The body segment splitter cuts along weight thresholds, not edge loops, so
the seg_hips top edge and the seg_leg_lower ankle edges are jagged rings
of "teeth" (~5-6 cm on average_m). On the skin this hides under the
neighbouring segment; on a garment shell it becomes a ragged silhouette.
The top ring's verts are pulled DOWN to the ring's own valley (deepest
notch) — a clean straight waist edge without inventing coverage. The
bottom rings' verts are moved onto `ankle_plane` (the ankle joint for
full-length pants, or the hem-cut plane for cropped variants) — a clean
straight hem AT the ankle. Only boundary verts move; interior verts are
untouched, and weights/UVs ride along, so skinning and painting are
unaffected. Returns (waist_plane, ankle_plane).
"""
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
boundary = set()
for e in bm.edges:
if len(e.link_faces) == 1: # open boundary
boundary.update(v.index for v in e.verts)
zs = [bm.verts[i].co.z for i in boundary]
z_mid = (min(zs) + max(zs)) / 2.0
top = [i for i in boundary if bm.verts[i].co.z > z_mid]
bottom = [i for i in boundary if bm.verts[i].co.z <= z_mid]
waist_plane = min(bm.verts[i].co.z for i in top)
teeth_top = max(bm.verts[i].co.z for i in top) - waist_plane
teeth_bot_lo = min(bm.verts[i].co.z for i in bottom)
teeth_bot_hi = max(bm.verts[i].co.z for i in bottom)
for i in top:
bm.verts[i].co.z = waist_plane
for i in bottom:
bm.verts[i].co.z = ankle_plane
bm.to_mesh(me)
bm.free()
me.update()
log(f"flattened open rims: waist ring ({len(top)} verts, teeth "
f"{teeth_top:.3f} m) -> {waist_plane:.3f}; ankle rings "
f"({len(bottom)} verts, z {teeth_bot_lo:.3f}..{teeth_bot_hi:.3f}) "
f"-> {ankle_plane:.3f}")
return waist_plane, ankle_plane
def waist_flare(shell, waist_plane, band_h, flare):
"""Extra RADIAL stand-off at the waistband rim, feathered over 2x the band
height (pre-solidify). QA evidence: in deep crouch the lower-back skin
folds over the waist rim (the peasant set's known 'deep-crouch waist gap',
worst on small bodies). Flaring the band outward gives the fold room and
reads as a natural jeans waistband stand-off."""
if flare <= 0.0:
return
z0 = waist_plane - 2.0 * band_h
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.normal_update()
n = 0
for v in bm.verts:
if v.co.z > z0:
t = min((v.co.z - z0) / (2.0 * band_h), 1.0)
nx, ny = v.normal.x, v.normal.y
mag = (nx * nx + ny * ny) ** 0.5
if mag > 1e-6:
v.co.x += flare * t * nx / mag
v.co.y += flare * t * ny / mag
n += 1
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"waist flare: {n} verts, +{flare * 1000:.1f} mm radial at rim "
f"(feathered from z={z0:.3f})")
def clamp_waist_residue(shell, waist_plane):
"""Post-solidify safety clamp: any interior tooth verts still above the
waist plane (multi-triangle teeth) get squashed onto it."""
me = shell.data
n = 0
for v in me.vertices:
if v.co.z > waist_plane:
v.co.z = waist_plane
n += 1
me.update()
if n:
log(f"clamped {n} residual waist verts -> {waist_plane:.3f}")
def hem_cut(shell, lm, hem_frac):
"""Trim the legs below the hem plane. 1.0 keeps the full ankle length."""
if hem_frac >= 0.999:
return lm.ankle_z
hip_z = float(lm.leg_z_pts[-1])
hem_z = lm.ankle_z + (1.0 - hem_frac) * (hip_z - lm.ankle_z)
bm = bmesh.new()
bm.from_mesh(shell.data)
doomed = [v for v in bm.verts if v.co.z < hem_z]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"hem cut at z={hem_z:.3f}: removed {len(doomed)} verts")
return hem_z
# --------------------------------------------------------------------------
# Denim feature field (texel-level; drives albedo AND mask together)
# --------------------------------------------------------------------------
def _seam_field(px, py, pz, lm):
"""Evaluate denim features at texel 3D positions (numpy arrays).
Returns bool arrays (seams, thread, in_cuff, in_band, front):
`seams` feeds the mask B channel; `thread` is every painted stitch line.
"""
w2 = lm.seam_w * 0.5
front = py * FRONT_Y_SIGN > 0.004
backside = py * FRONT_Y_SIGN < -0.004
in_band = pz >= lm.band_z
in_cuff = pz <= lm.cuff_top
mid = (~in_band) & (~in_cuff)
# Per-z leg axis, mirrored by x sign.
side = np.where(px >= 0.0, 1.0, -1.0)
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
dx = px - cx
dy = py - cy
r = np.hypot(dx, dy) + 1e-9
# Outseam: azimuth toward the outer (+/-x) direction; constant metric
# width via arc distance. Inseam: inner direction, below the crotch only.
arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
outseam = mid & (arc_out < w2)
arc_in = np.arccos(np.clip(-dx * side / r, -1.0, 1.0)) * r
inseam = mid & (arc_in < w2) & (pz < lm.crotch_z - 0.01 * lm.span / _REF_SPAN)
# Centre-front fly stitch (slightly off-centre, classic J-front).
fly = front & mid & (np.abs(px - 0.012 * lm.sh) < w2) \
& (pz > lm.crotch_z + 0.015 * lm.span / _REF_SPAN)
# Back yoke: shallow V, higher toward the sides.
yoke_z = (lm.band_z - YOKE_DROP_FRAC * lm.span
+ YOKE_RISE_FRAC * lm.span
* np.minimum(np.abs(px) / (1.5 * lm.hip_x), 1.0))
yoke = backside & mid & (np.abs(pz - yoke_z) < w2) \
& (np.abs(px) < 1.6 * lm.hip_x)
seams = outseam | inseam | fly | yoke
# Stitch-only lines (albedo, not mask): waist + cuff border stitching.
wstitch = np.abs(pz - lm.band_z) < w2
cstitch = np.abs(pz - lm.cuff_top) < w2
# Back pocket outlines (rectangle rings on the seat).
bhw = BPOCKET_HW_FRAC * lm.hip_x
bhh = BPOCKET_HH_FRAC * lm.span
bcz = lm.band_z - BPOCKET_TOP_FRAC * lm.span - bhh
adx = np.abs(np.abs(px) - BPOCKET_CX_FRAC * lm.hip_x)
adz = np.abs(pz - bcz)
bpocket = backside & (adx < bhw + w2) & (adz < bhh + w2) \
& ~((adx < bhw - w2) & (adz < bhh - w2))
# Front pocket arcs (quarter-ellipse from waistband toward the outseam).
fcx = FPOCKET_CX_FRAC * lm.hip_x
frx = FPOCKET_RX_FRAC * lm.hip_x
frz = FPOCKET_RZ_FRAC * lm.span
ex = (np.abs(px) - fcx) / frx
ez = (pz - lm.band_z) / frz
fdist = (np.sqrt(ex * ex + ez * ez) - 1.0) * (0.5 * (frx + frz))
fpocket = front & (np.abs(fdist) < w2) & (pz <= lm.band_z) \
& (np.abs(px) <= fcx)
thread = seams | wstitch | cstitch | bpocket | fpocket
return seams, thread, in_cuff, in_band, front
def _paint_texels(px, py, pz, noise, lm):
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
n = px.shape[0]
seams, thread, in_cuff, in_band, front = _seam_field(px, py, pz, lm)
# --- albedo ------------------------------------------------------------
alb = np.empty((n, 4), dtype=np.float32)
for c in range(3):
alb[:, c] = DENIM_RGB[c] + noise
alb[:, 3] = 1.0
alb[in_cuff, :3] *= CUFF_SHADE
if not PLAIN:
# Belt loops: darkened denim bands on the waistband.
loop_w = LOOP_W_M * lm.sh
loops = np.zeros(n, dtype=bool)
for fx in LOOP_X_FRACS:
loops |= np.abs(np.abs(px) - fx * lm.hip_x) < loop_w * 0.5
loops |= (~front) & (np.abs(px) < loop_w * 0.5) # centre-back loop
loops &= in_band
alb[loops, :3] *= LOOP_SHADE
alb[thread, 0] = THREAD_RGB[0]
alb[thread, 1] = THREAD_RGB[1]
alb[thread, 2] = THREAD_RGB[2]
# Waist button (front, mid-band).
btn = front & (np.hypot(px, pz - (lm.band_z + 0.5 * lm.band_h))
< 0.009 * lm.sh)
alb[btn, 0] = BUTTON_RGB[0]
alb[btn, 1] = BUTTON_RGB[1]
alb[btn, 2] = BUTTON_RGB[2]
# --- region mask: waistband R / legs G / seams+cuffs B ------------------
mask = np.zeros((n, 4), dtype=np.float32)
is_b = (in_cuff | seams) & ~in_band
is_g = ~(in_band | is_b)
mask[in_band, 0] = 1.0
mask[is_b, 2] = 1.0
mask[is_g, 1] = 1.0
return alb, mask
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
evaluate the denim field, write albedo + mask together."""
a, b, c = uvs
A, B, C = cos
ax, ay = a.x * (W - 1), a.y * (H - 1)
bx, by = b.x * (W - 1), b.y * (H - 1)
cx, cy = c.x * (W - 1), c.y * (H - 1)
minx = max(int(np.floor(min(ax, bx, cx))), 0)
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
miny = max(int(np.floor(min(ay, by, cy))), 0)
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
if minx > maxx or miny > maxy:
return
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
if abs(denom) < 1e-9:
return
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
pxg = xs + 0.5
pyg = ys + 0.5
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
w2 = 1.0 - w0 - w1
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
if not inside.any():
return
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
px3 = w0i * A.x + w1i * B.x + w2i * C.x
py3 = w0i * A.y + w1i * B.y + w2i * C.y
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
ysin = ys[inside]
xsin = xs[inside]
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
alb_buf[ysin, xsin] = alb
mask_buf[ysin, xsin] = mask
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
"""Rasterize all UV0 triangles once, producing the painted albedo and the
region mask from one shared feature-field evaluation per texel."""
W = H = TEX_SIZE
rng = np.random.default_rng(NOISE_SEED)
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
* 2.0 * ALBEDO_NOISE)
alb_buf = np.empty((H, W, 4), dtype=np.float32)
for c in range(3):
alb_buf[:, :, c] = DENIM_RGB[c] + noise_buf
alb_buf[:, :, 3] = 1.0
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
mask_buf[:, :, 1] = 1.0 # background = legs green (bleed-safe)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
if not len(bm.loops.layers.uv):
raise RuntimeError("no UV layer for albedo/mask paint")
uv_layer = bm.loops.layers.uv[0]
tri_count = 0
for face in bm.faces:
loops = face.loops[:]
uvs = [loop[uv_layer].uv.copy() for loop in loops]
cos = [loop.vert.co.copy() for loop in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_paint(
alb_buf, mask_buf, noise_buf,
(uvs[0], uvs[i], uvs[i + 1]),
(cos[0], cos[i], cos[i + 1]),
lm, W, H)
tri_count += 1
bm.free()
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
def _save(buf, name, path):
img = bpy.data.images.new(name, W, H, alpha=True)
img.pixels.foreach_set(buf.reshape(-1))
img.update()
img.filepath_raw = path
img.file_format = 'PNG'
img.save()
return img
albedo_img = _save(alb_buf, f"denim_albedo_{body}", albedo_path)
_save(mask_buf, f"denim_mask_{body}", mask_path)
log(f"saved albedo -> {albedo_path}")
log(f"saved mask -> {mask_path}")
return albedo_img
# --------------------------------------------------------------------------
# Parked logo UV2 (pants are not logo-capable; the shader still samples UV2)
# --------------------------------------------------------------------------
def author_parked_uv2(shell):
me = shell.data
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
me.uv_layers.new(name="logo_uv")
me.uv_layers.active = me.uv_layers[0]
bm = bmesh.new()
bm.from_mesh(me)
uvl = bm.loops.layers.uv.get("logo_uv")
for face in bm.faces:
for loop in face.loops:
loop[uvl].uv = (2.0, 2.0)
bm.to_mesh(me)
bm.free()
me.update()
log("logo UV2 authored fully parked (not logo-capable)")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_denim_shell(body_dir, out_dir, body, offset, hem_frac):
crotch_z, _hips_top = probe_hips_bounds(body_dir)
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
weld_boundaries(shell)
zs = [v.co.z for v in shell.data.vertices]
waist_z, ankle_z = max(zs), min(zs)
lm = LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
hem_z = hem_cut(shell, lm, hem_frac)
# Full-length pants hem AT the ankle joint (calf tail); cropped variants
# hem at the cut plane.
ankle_plane = float(lm.leg_z_pts[0]) if hem_frac >= 0.999 else hem_z
waist_plane, ankle_plane = flatten_open_rims(shell, ankle_plane)
base.offset_outward(shell, offset)
waist_flare(shell, waist_plane,
BAND_FRAC * (waist_plane - ankle_plane), WAIST_FLARE_M)
base.solidify(shell, base.CLOTH_THICKNESS_M)
clamp_waist_residue(shell, waist_plane)
# Landmarks reference the CLEAN rims (band under the flattened waist edge,
# cuff above the flattened hem).
lm.waist_z = waist_plane
lm.finalize(ankle_plane)
author_parked_uv2(shell)
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
mask_path = os.path.join(out_dir, f"{body}_mask.png")
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
base.assign_fabric_material(shell, albedo_img)
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
def main():
global HEM_FRAC, DENIM_RGB, PLAIN, WAIST_FLARE_M
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
"[--offset M] [--hem-frac F] [--waist-flare M] "
"[--base-rgb r,g,b] [--plain]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = base.PER_BODY_OFFSET_M
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--hem-frac" in argv:
HEM_FRAC = float(argv[argv.index("--hem-frac") + 1])
if "--waist-flare" in argv:
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
if "--base-rgb" in argv:
DENIM_RGB = tuple(
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
if "--plain" in argv:
PLAIN = True
bodies = base.BODY_TYPES
if "--bodies" in argv:
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
os.makedirs(out_dir, exist_ok=True)
log(f"denim per-body mode: {len(bodies)} bodies, offset "
f"{offset * 1000:.0f} mm, hem-frac {HEM_FRAC}, plain={PLAIN}")
results = []
for body in bodies:
body_dir = os.path.join(bodies_root, body)
log(f"=== {body} ===")
if not os.path.isdir(body_dir):
results.append((body, "skipped: body dir missing"))
continue
try:
author_denim_shell(body_dir, out_dir, body, offset, HEM_FRAC)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
import traceback
traceback.print_exc()
results.append((body, f"error: {exc}"))
ref = base.REFERENCE_BODY
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
if os.path.isfile(ref_mask):
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
if os.path.isfile(ref_alb):
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
log("=" * 50)
for body, status in results:
log(f" {body:12s} {status}")
ok = sum(1 for _, s in results if s == "ok")
log(f"OK={ok}/{len(results)}")
if ok != len(results):
sys.exit(1)
log("DONE")
if __name__ == "__main__":
main()
@@ -0,0 +1,590 @@
"""
blender_author_hoodie.py (T-1089 — hoodie_modern, per-body offset-shell)
Hooded-top companion to blender_author_offset_shell.py: reuses the base
module's segment join / bone-ratio thresholds / offset / solidify / logo-UV2 /
export machinery and layers the hoodie-specific geometry + texture identity on
top. Everything spatial is derived from bone landmarks (ratios of neck_01
length, shoulder |x|, spine span) so the same parameters produce a
proportionally identical garment on all 11 bodies. The parameter block below
is the reusable seam for the rest of the hooded/long-sleeve family
(track_jacket, sweater_modern): import this module and override.
Hoodie deltas over the base t-shirt shell:
* covers torso + torso_upper + FULL arms (long sleeves, wrist-cut on the
lowerarm bone instead of the upperarm short-sleeve cut)
* HOOD DOWN — a rolled collar bulk ring: collar-band verts are inflated
radially away from the neck axis (back-biased, slight upward lift) before
solidify, so the roll gets real thickness and caps into a ring
* looser standoff (16 mm vs the 12 mm per-body tee) + thicker cloth (6 mm)
* painted albedo identity (texture-carried, per body because UV0 face
classification is per body): kangaroo pocket fill + stitch outline,
drawstrings, ribbed hem + cuff bands — all painted as LUMINANCE detail so
the luma-preserving toon_garment recolor keeps them under any tint
* region mask: R = collar/hood roll (asymmetric drop — drapes lower on the
back), G = body, B = sleeves + kangaroo pocket
* logo-capable chest via the base UV2 channel (unchanged)
PER-BODY ONLY (Q-060: offset-shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_hoodie.py -- \
client/assets/characters/bodies client/assets/characters/clothing/hoodie_modern \
[--bodies a,b,c] [--offset M]
Writes per body: <out_dir>/<body>.glb, <body>_mask.png, <body>_base_albedo.png
Plus fallbacks: <out_dir>/base_albedo.png + reference_mask.png (= average_m's).
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
"""
import importlib.util
import math
import os
import shutil
import sys
import bpy
import bmesh
import numpy as np
from mathutils import Vector
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load_base():
spec = importlib.util.spec_from_file_location(
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
base = _load_base()
# --------------------------------------------------------------------------
# Hoodie parameters (the reusable seam — override for track_jacket/sweater)
# --------------------------------------------------------------------------
GARMENT_ID = "hoodie_modern"
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.016 # loose standoff (per-body construction guarantees it)
THICKNESS_M = 0.006 # fleece-weight cloth
WRIST_FRAC = 0.92 # fraction of the lowerarm kept (sleeve ends at wrist)
# Hood-down roll: fractions of neck_01 length unless noted.
ROLL_OUT_FRAC = 0.40 # radial bulge magnitude
ROLL_LIFT_FRAC = 0.14 # upward lift at the rim
ROLL_Z_START_FRAC = 0.45 # roll influence starts this far below collar_z_min
ROLL_REACH_COLLAR_X = 1.7 # candidate radius around the neck axis (x collar_x_abs)
ROLL_FRONT_GAIN = 0.55 # bulge scale at the front...
ROLL_BACK_GAIN = 1.10 # ...and at the back (hood mass hangs behind)
ROLL_EXPONENT = 1.7 # falloff sharpness toward the rim
# Region mask R (roll) band: drop below collar_z_min, x neck_len, per side.
R_Z_DROP_FRONT = 0.15
R_Z_DROP_BACK = 0.55
# Kangaroo pocket: z as fractions of the waistband-top -> chest_z_lo span
# (sits ABOVE the ribbed hem band), x as fractions of shoulder |x|.
POCKET_Z0_FRAC = 0.06
POCKET_Z1_FRAC = 0.88
POCKET_WB_FRAC = 0.56 # bottom half-width
POCKET_WT_FRAC = 0.30 # top half-width (diagonal hand openings)
POCKET_FILL_MULT = 0.95 # subtle patch shading
POCKET_LINE_MULT = 0.70 # stitch outline darkening
POCKET_LINE_PX = 2 # outline thickness (erosion iterations)
# Drawstrings (front only): x offset / half-width as fractions of collar_x_abs,
# z as fractions of neck_len.
STRING_X_FRAC = 0.30
STRING_HALF_W_M = 0.005
STRING_TOP_DROP_FRAC = 0.10
STRING_LEN_FRAC = 0.75
STRING_MULT = 0.50
# Ribbed bands: hem (x spine span) and cuffs (x lowerarm length). The cuff is
# anchored to the mesh's ACTUAL post-cut reach, not the nominal wrist plane —
# the vert-threshold cut leaves a jagged face boundary that stops 1-3 cm short
# of the plane, so a nominal-anchored band would mostly land on deleted faces.
HEM_BAND_FRAC = 0.08
CUFF_LEN_FRAC = 0.16
BAND_MULT = 0.85
BAND_LINE_MULT = 0.72
BAND_LINE_M = 0.004
# Muted street-tone fabric (luma drives the toon_garment recolor).
FABRIC_RGB = (0.55, 0.56, 0.58)
FABRIC_NOISE = 0.035
ALBEDO_SEED = 1091
def log(msg):
print(f"[hoodie] {msg}")
# --------------------------------------------------------------------------
# Landmarks beyond the base thresholds
# --------------------------------------------------------------------------
def derive_landmarks(armature, thr):
"""Hoodie-specific bone landmarks (all in body-local metres)."""
bones = armature.data.bones
neck = bones.get("neck_01")
la_l = bones.get("lowerarm_l")
la_r = bones.get("lowerarm_r")
if not all([neck, la_l, la_r]):
raise RuntimeError("landmark bones missing (neck_01 / lowerarm_l/r)")
neck_len = neck.tail_local.z - neck.head_local.z
lm = {
"neck_y": neck.head_local.y,
"neck_len": neck_len,
"roll_out": ROLL_OUT_FRAC * neck_len,
"roll_lift": ROLL_LIFT_FRAC * neck_len,
"roll_reach": ROLL_REACH_COLLAR_X * thr["collar_x_abs"],
# wrist cut thresholds per side: (sign, x threshold)
"wrist_cuts": [],
"lowerarm_len": 0.0,
}
for b, sign in [(la_l, +1), (la_r, -1)]:
head_x, tail_x = b.head_local.x, b.tail_local.x
thr_x = head_x + WRIST_FRAC * (tail_x - head_x)
lm["wrist_cuts"].append((sign, thr_x))
lm["lowerarm_len"] = abs(tail_x - head_x)
log(f"landmarks: neck_len {neck_len:.4f} roll_out {lm['roll_out']*1000:.0f}mm "
f"reach {lm['roll_reach']:.3f} wrist cuts "
+ " ".join(f"{s:+d}@{t:.3f}" for s, t in lm["wrist_cuts"]))
return lm
# --------------------------------------------------------------------------
# Geometry: wrist cut + hood roll
# --------------------------------------------------------------------------
def wrist_cut(shell, lm):
"""Trim the sleeve tubes at the wrist plane (same pattern as base.sleeve_cut,
but on the lowerarm bone so the sleeves stay full length)."""
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 lm["wrist_cuts"]:
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 hood_roll(shell, thr, lm):
"""Inflate collar-band verts radially away from the neck axis to read as a
rolled-down hood: back-biased bulge + slight rim lift. Runs after the
outward offset and before solidify (the roll then gets cloth thickness)."""
z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * lm["neck_len"]
reach = lm["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 - lm["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 - lm["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 * (lm["roll_out"] * w * gain)
v.co.z += lm["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})")
# --------------------------------------------------------------------------
# Paint maps: rasterize body-space (x, z) into UV0 texel space
# --------------------------------------------------------------------------
def _raster_tri_attr(maps, a, b, c, xs, zs, is_front, is_back, W, H):
"""Barycentric rasterization interpolating body-space x/z per texel."""
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, px_x = np.mgrid[miny:maxy + 1, minx:maxx + 1]
px = px_x + 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
x_val = w0 * xs[0] + w1 * xs[1] + w2 * xs[2]
z_val = w0 * zs[0] + w1 * zs[1] + w2 * zs[2]
sl = (slice(miny, maxy + 1), slice(minx, maxx + 1))
maps["X"][sl][inside] = x_val[inside]
maps["Z"][sl][inside] = z_val[inside]
maps["valid"][sl][inside] = True
if is_front:
maps["front"][sl][inside] = True
if is_back:
maps["back"][sl][inside] = True
def bake_paint_maps(shell, W, H):
"""Rasterize the shell's UV0 layout into per-texel body-space X/Z maps,
with front/back facing flags (front = -Y on this rig)."""
maps = {
"X": np.zeros((H, W), dtype=np.float32),
"Z": np.zeros((H, W), dtype=np.float32),
"valid": np.zeros((H, W), dtype=bool),
"front": np.zeros((H, W), dtype=bool),
"back": np.zeros((H, W), dtype=bool),
}
bm = bmesh.new()
bm.from_mesh(shell.data)
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 paint-map bake")
for face in bm.faces:
n_front = face.normal.y * base.FRONT_Y_SIGN
c_front = face.calc_center_median().y * base.FRONT_Y_SIGN
is_front = n_front > 0.15 and c_front > 0.0
is_back = n_front < -0.15 and c_front < 0.0
loops = face.loops[:]
uvs = [lp[uv_layer].uv.copy() for lp in loops]
cos = [lp.vert.co for lp in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_attr(
maps, uvs[0], uvs[i], uvs[i + 1],
(cos[0].x, cos[i].x, cos[i + 1].x),
(cos[0].z, cos[i].z, cos[i + 1].z),
is_front, is_back, W, H)
bm.free()
overlap = int((maps["front"] & maps["back"]).sum())
log(f"paint maps: {int(maps['valid'].sum())} texels "
f"(front {int(maps['front'].sum())}, back {int(maps['back'].sum())}, "
f"front/back UV overlap {overlap})")
dump = os.environ.get("HOODIE_DEBUG_MAPS", "")
if dump:
np.savez_compressed(dump, **maps)
log(f"debug maps dumped -> {dump}")
return maps
# --------------------------------------------------------------------------
# Painted albedo (texture-carried modern identity)
# --------------------------------------------------------------------------
def derive_paint_params(shell, thr, lm):
"""Body-space paint geometry, derived from thresholds + final shell mesh."""
sleeve_x = thr["sleeve_x_abs"]
hem_z = min((v.co.z for v in shell.data.vertices
if abs(v.co.x) < sleeve_x), default=1.0)
# Actual sleeve reach from the final mesh (jagged post-cut boundary).
wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=sleeve_x)
chest_lo = thr["chest_z"][0]
hem_band = HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
hem_top = hem_z + hem_band
span = chest_lo - hem_top
shoulder_x = thr["chest_x"][1] / base.CHEST_X_FRAC # invert base ratio
p = {
"hem_z": hem_z,
"hem_band": hem_band,
"pocket_z0": hem_top + POCKET_Z0_FRAC * span,
"pocket_z1": hem_top + POCKET_Z1_FRAC * span,
"pocket_wb": POCKET_WB_FRAC * shoulder_x,
"pocket_wt": POCKET_WT_FRAC * shoulder_x,
"string_x": STRING_X_FRAC * thr["collar_x_abs"],
"string_z_top": thr["collar_z_min"] - STRING_TOP_DROP_FRAC * lm["neck_len"],
"string_len": STRING_LEN_FRAC * lm["neck_len"],
"cuff_len": CUFF_LEN_FRAC * lm["lowerarm_len"],
"wrist_x": wrist_x,
"sleeve_x": sleeve_x,
}
log(f"paint params: hem {hem_z:.3f} pocket z ({p['pocket_z0']:.3f},"
f"{p['pocket_z1']:.3f}) w ({p['pocket_wt']:.3f}->{p['pocket_wb']:.3f})")
return p
def _erode(m, iters):
for _ in range(iters):
m = (m
& np.roll(m, 1, 0) & np.roll(m, -1, 0)
& np.roll(m, 1, 1) & np.roll(m, -1, 1))
return m
def pocket_mask(maps, p):
"""Kangaroo-pocket texel mask: front-only trapezoid with diagonal sides."""
X, Z = maps["X"], maps["Z"]
paintable = maps["front"] & ~maps["back"]
z0, z1 = p["pocket_z0"], p["pocket_z1"]
if z1 <= z0:
return np.zeros_like(paintable)
t = np.clip((z1 - Z) / (z1 - z0), 0.0, 1.0) # 1 at bottom, 0 at top
half_w = p["pocket_wt"] + (p["pocket_wb"] - p["pocket_wt"]) * t
inside = paintable & (Z >= z0) & (Z <= z1) & (np.abs(X) <= half_w)
log(f"pocket mask: {int(inside.sum())} texels")
return inside
def make_painted_albedo(maps, pocket_px, p, out_dir, body):
"""Flat street-tone fabric + painted luminance detail; returns bpy image."""
W = H = base.ALBEDO_SIZE
rng = np.random.default_rng(ALBEDO_SEED)
fabric = np.array(FABRIC_RGB, dtype=np.float32)
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE
rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
X, Z = maps["X"], maps["Z"]
valid = maps["valid"]
front_only = maps["front"] & ~maps["back"]
# Ribbed hem band (all around) + top stitch line.
hem_top = p["hem_z"] + p["hem_band"]
band = valid & (Z <= hem_top) & (np.abs(X) <= p["sleeve_x"])
rgb[band] *= BAND_MULT
line = valid & (np.abs(Z - hem_top) <= BAND_LINE_M) & (np.abs(X) <= p["sleeve_x"])
rgb[line] *= BAND_LINE_MULT
# Ribbed cuffs (all around) + inner stitch line.
cuff_x0 = p["wrist_x"] - p["cuff_len"]
cuff = valid & (np.abs(X) >= cuff_x0)
rgb[cuff] *= BAND_MULT
cline = valid & (np.abs(np.abs(X) - cuff_x0) <= BAND_LINE_M)
rgb[cline] *= BAND_LINE_MULT
log(f"paint counts: hem {int(band.sum())} hemline {int(line.sum())} "
f"cuff {int(cuff.sum())} cuffline {int(cline.sum())} "
f"(hem_top {hem_top:.3f}, cuff_x0 {cuff_x0:.3f})")
zs = Z[valid]
xs_v = np.abs(X[valid])
log(f"map ranges: Z [{zs.min():.3f},{zs.max():.3f}] "
f"|X| [{xs_v.min():.3f},{xs_v.max():.3f}] "
f"Z<=hem_top {int((zs <= hem_top).sum())} "
f"|X|>=cuff_x0 {int((xs_v >= cuff_x0).sum())}")
# Kangaroo pocket: subtle fill + dark stitch outline.
rgb[pocket_px] *= POCKET_FILL_MULT
outline = pocket_px & ~_erode(pocket_px, POCKET_LINE_PX)
rgb[outline] *= POCKET_LINE_MULT
# Drawstrings (front only, hanging from the collar).
z_top = p["string_z_top"]
z_bot = z_top - p["string_len"]
for sx in (+p["string_x"], -p["string_x"]):
s = front_only & (np.abs(X - sx) <= STRING_HALF_W_M) \
& (Z >= z_bot) & (Z <= z_top)
rgb[s] *= STRING_MULT
rgba = np.concatenate([rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
img = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False)
img.pixels.foreach_set(rgba.reshape(-1))
img.update()
sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
img.filepath_raw = sidecar
img.file_format = 'PNG'
img.save()
log(f"painted albedo -> {sidecar}")
# 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
# name above (<body>_base_albedo.png, same pixels — tshirt convention),
# instead of a doubled <body>_<body>_base_albedo.png. The shared file is
# re-pointed to the reference body's paint at the end of the run.
img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
img.save()
return img
# --------------------------------------------------------------------------
# Region mask: R = hood roll, G = body, B = sleeves + pocket
# --------------------------------------------------------------------------
def _classify_hoodie(center, thr, lm):
x, y, z = center.x, center.y, center.z
if abs(x) >= thr["sleeve_x_abs"]:
return (0.0, 0.0, 1.0, 0.0) # sleeves -> B
hd = math.hypot(x, y - lm["neck_y"])
is_front = y * base.FRONT_Y_SIGN > 0.0
drop = R_Z_DROP_FRONT if is_front else R_Z_DROP_BACK
z_min = thr["collar_z_min"] - drop * lm["neck_len"]
if z >= z_min and hd <= lm["roll_reach"] + lm["roll_out"] + 0.01:
return (1.0, 0.0, 0.0, 0.0) # hood roll -> R
return (0.0, 1.0, 0.0, 0.0) # body -> G
def bake_hoodie_mask(shell, out_path, thr, lm, pocket_px):
"""Per-face region rasterization (like base.bake_region_mask) with the
hoodie classifier, then the pocket texels overlaid into B."""
W = H = base.MASK_SIZE
buf = np.zeros((H, W, 4), dtype=np.float32)
buf[:, :, 1] = 1.0 # body-green background (bleed safety)
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.faces.ensure_lookup_table()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for region mask bake")
counts = {"roll": 0, "body": 0, "sleeve": 0}
for face in bm.faces:
color = _classify_hoodie(face.calc_center_median(), thr, lm)
if color[0] > 0.5:
counts["roll"] += 1
elif color[2] > 0.5:
counts["sleeve"] += 1
else:
counts["body"] += 1
for a, b, c in base._tris_from_face(face, uv_layer):
base._raster_tri(buf, a, b, c, color, 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()))
# Pocket joins the sleeve tint region (B), per the spec.
if pocket_px is not None and pocket_px.shape == (H, W):
buf[pocket_px] = (0.0, 0.0, 1.0, 0.0)
img = bpy.data.images.new(f"{GARMENT_ID}_mask", W, H, alpha=True)
img.pixels.foreach_set(buf.reshape(-1))
img.update()
img.filepath_raw = out_path
img.file_format = 'PNG'
img.save()
log(f"baked region mask -> {out_path}")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_hoodie(body_dir, out_dir, body, offset):
base.clear_scene()
shell, armature = base.build_covered_mesh(body_dir)
thr = base.derive_thresholds(armature)
lm = derive_landmarks(armature, thr)
wrist_cut(shell, lm)
base.offset_outward(shell, offset)
hood_roll(shell, thr, lm)
base.solidify(shell, THICKNESS_M)
maps = bake_paint_maps(shell, base.ALBEDO_SIZE, base.ALBEDO_SIZE)
p = derive_paint_params(shell, thr, lm)
pocket_px = pocket_mask(maps, p)
albedo_img = make_painted_albedo(maps, pocket_px, p, out_dir, body)
base.assign_fabric_material(shell, albedo_img)
base.author_logo_uv(shell, thr)
# Mask texels are MASK_SIZE; paint maps are ALBEDO_SIZE. Sizes match (512)
# today; rebake the pocket mask if they ever diverge.
mask_pocket = pocket_px if base.MASK_SIZE == base.ALBEDO_SIZE else None
bake_hoodie_mask(shell, os.path.join(out_dir, f"{body}_mask.png"),
thr, lm, mask_pocket)
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]")
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])
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)
base.COVERED_SEGMENTS = SEGMENTS
log(f"per-body hoodie: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
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_hoodie(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}"))
# 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")
ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
if os.path.isfile(ref_albedo):
shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
log("copied base_albedo.png fallback")
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,529 @@
"""
blender_author_jacket_shell.py (T-1089, route (c) hand-author proof: suit jacket)
Jacket-family companion to blender_author_offset_shell.py — imports that module
as a library (segments -> join -> offset -> solidify -> skinned export are
reused unchanged) and adds what a JACKET needs beyond a t-shirt:
* full-arm coverage (torso + torso_upper + upper AND lower arms) with a
WRIST cut on the lowerarm bone (long sleeves) instead of an upper-arm cut;
* a boundary WELD after the segment join, so the elbow/shoulder segment
seams offset as one continuous surface (no gap rings on long sleeves);
* a jacket standoff (default 18 mm — outerwear drape over the 12 mm tee);
* a PAINTED albedo + region mask baked together in ONE per-pixel
rasterization pass: each texel's 3D body-local position is interpolated
barycentric-ally, so the V-opening / lapels / shirt triangle have crisp
edges independent of the low-poly tessellation (the base script's
per-FACE classification would read chunky on a chest V).
Region-mask convention for the suit (toon_garment.gdshader tint routing):
R = lapels + collar band (tint_0 — subtle satin two-tone)
G = jacket body (tint_1)
B = sleeves (tint_2)
A = shirt triangle in the V (tint_3 — default WHITE so outfits can
recolor the visible shirt)
Painted albedo details (luma-carried; the shader recolors hue per region but
keeps albedo luminance, so detail must live in the 0.53-max luma band or the
`luma*1.5+0.2` curve clips it): white-shirt triangle with placket + buttons,
lapel fold + edge shading, front closure seam + jacket button below the V,
sleeve cuff band. All geometry parameters are bone-landmark RATIOS (same
anchors as the base script: shoulder = upperarm head |x|, neck_01, spine_01,
plus lowerarm for the wrist), so every body derives its own proportional
cut/paint constants — calibrated to the intended metric sizes on average_m.
PER-BODY ONLY: offset-shell garments are authored per body (Q-060 route
guidance) — there is no single-reference mode here.
tooling/blender --background --python \
tooling/garment-fit/blender_author_jacket_shell.py -- \
<bodies_root> <out_dir> \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-frac 0.85]
Writes per body:
<out_dir>/<body>.glb skinned jacket authored on that body
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0)
Plus:
<out_dir>/base_albedo.png copy of average_m's painted albedo
<out_dir>/reference_mask.png copy of average_m's mask (runtime fallback)
The painted per-body albedo is embedded in each GLB (image URI basename
"base_albedo", staged under .cache/garment-fit-suit/<body>/); on import
Godot extracts it to <out_dir>/<body>_base_albedo.png — the same layout the
t-shirt reference garment has.
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
"""
import sys
import os
import shutil
import bpy
import bmesh
import numpy as np
# Import the base offset-shell module as a library.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import blender_author_offset_shell as base # noqa: E402
# --------------------------------------------------------------------------
# Jacket parameters
# --------------------------------------------------------------------------
JACKET_SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
]
JACKET_OFFSET_M = 0.018 # outerwear standoff (tee is 12 mm per-body)
WRIST_FRAC = 0.92 # fraction of lowerarm length kept. NB the vert-delete
# cut also drops faces touching deleted verts, so the
# visible sleeve ends ~1 low-poly ring earlier — 0.92
# lands the hem just above the wrist (matches the
# buttondown's calibrated SLEEVE_END_FRAC).
CUFF_FRAC = 0.14 # last fraction of the KEPT sleeve painted as cuff band
WELD_DIST_M = 0.0001 # merge distance for segment-boundary weld
BAKE_SIZE = 1024 # albedo + mask resolution (512 is too coarse for the
# 6 mm placket / button dots on the torso island)
# --- V-opening / lapel geometry, as ratios of average_m bone landmarks -----
# (anchors identical to base: shoulder |x| 0.1919, neck head z 1.5205,
# spine_01 head z 1.072 -> spine span 0.4485)
V_HALF_TOP_M = 0.068 # V half-width at the collar on average_m
V_BOT_Z_M = 1.17 # V bottom (jacket closure point) on average_m
LAPEL_W_M = 0.045 # lapel band width
LAPEL_DROP_M = 0.020 # lapel wraps slightly below the V point
EDGE_W_M = 0.011 # lapel fold/edge shading line width (albedo only)
PLACKET_HALF_M = 0.006 # shirt placket half-width
CLOSURE_HALF_M = 0.0045 # jacket front closure seam half-width
SHIRT_BTN_R_M = 0.0055 # shirt button radius
JACKET_BTN_R_M = 0.009 # jacket button radius
JACKET_BTN_DROP_M = 0.030 # jacket button sits this far below the V point
SHIRT_BTN_FRACS = (0.11, 0.30) # shirt button z, as frac of (v_top - v_bot)
V_HALF_TOP_FRAC = V_HALF_TOP_M / base._REF_SHOULDER_X
LAPEL_W_FRAC = LAPEL_W_M / base._REF_SHOULDER_X
EDGE_W_FRAC = EDGE_W_M / base._REF_SHOULDER_X
PLACKET_HALF_FRAC = PLACKET_HALF_M / base._REF_SHOULDER_X
CLOSURE_HALF_FRAC = CLOSURE_HALF_M / base._REF_SHOULDER_X
SHIRT_BTN_R_FRAC = SHIRT_BTN_R_M / base._REF_SHOULDER_X
JACKET_BTN_R_FRAC = JACKET_BTN_R_M / base._REF_SHOULDER_X
_REF_SPAN = base._REF_NECK_Z - base._REF_SPINE_LO_Z
V_BOT_FRAC = (V_BOT_Z_M - base._REF_SPINE_LO_Z) / _REF_SPAN
LAPEL_DROP_FRAC = LAPEL_DROP_M / _REF_SPAN
JACKET_BTN_DROP_FRAC = JACKET_BTN_DROP_M / _REF_SPAN
# --- Painted albedo luma palette (neutral hue; shader tints supply color) ---
# Kept inside the luma*1.5+0.2 <= 1.0 budget (luma <= 0.53) so painted detail
# survives a white tint on the shirt region.
LUMA_FABRIC = 0.400 # jacket body + sleeves + back
LUMA_SATIN = 0.475 # lapels + collar band (subtle two-tone vs body)
LUMA_EDGE = 0.250 # lapel fold/edge shading lines
LUMA_SHIRT = 0.520 # shirt triangle (renders ~white under white tint)
LUMA_PLACKET = 0.440 # shirt placket strip
LUMA_SHIRT_BTN = 0.320 # shirt buttons
LUMA_CLOSURE = 0.290 # jacket closure seam below the V
LUMA_JACKET_BTN = 0.180 # jacket button
LUMA_CUFF = 0.330 # sleeve cuff band
ALBEDO_NOISE = 0.018 # +/- woven jitter (4-px blocks — compresses well)
ALBEDO_TINT = (1.0, 1.0, 1.03) # slightly cool cast on the neutral greys
log = base.log
# --------------------------------------------------------------------------
# Suit-specific threshold derivation (extends base.derive_thresholds)
# --------------------------------------------------------------------------
def derive_jacket_thresholds(armature, wrist_frac):
thr = base.derive_thresholds(armature)
bones = armature.data.bones
ua_l = bones.get("upperarm_l")
neck = bones.get("neck_01")
spine01 = bones.get("spine_01")
if ua_l and neck and spine01:
ua_r = bones.get("upperarm_r")
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
spine_lo = spine01.head_local.z
span = neck.head_local.z - spine_lo
else:
log("WARNING: landmark bones missing — legacy average_m jacket constants")
shoulder_x = base._REF_SHOULDER_X
spine_lo = base._REF_SPINE_LO_Z
span = _REF_SPAN
v_top = thr["collar_z_min"]
v_bot = spine_lo + V_BOT_FRAC * span
thr.update({
"v_top_z": v_top,
"v_bot_z": v_bot,
"v_half_top": shoulder_x * V_HALF_TOP_FRAC,
"lapel_w": shoulder_x * LAPEL_W_FRAC,
"lapel_drop": LAPEL_DROP_FRAC * span,
"edge_w": shoulder_x * EDGE_W_FRAC,
"placket_half": shoulder_x * PLACKET_HALF_FRAC,
"closure_half": shoulder_x * CLOSURE_HALF_FRAC,
"shirt_btn_r": shoulder_x * SHIRT_BTN_R_FRAC,
"jacket_btn_r": shoulder_x * JACKET_BTN_R_FRAC,
"jacket_btn_z": v_bot - JACKET_BTN_DROP_FRAC * span,
"shirt_btn_zs": [v_bot + f * (v_top - v_bot) for f in SHIRT_BTN_FRACS],
})
# Wrist cut planes + cuff band start, from the lowerarm bones (arms run
# along +/-X in rest pose, elbow head -> wrist tail).
cut_planes = [] # (sign, wrist_thr_x, cuff_start_x)
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 uncut on that side")
continue
head_x = b.head_local.x
tail_x = b.tail_local.x
wrist_thr = head_x + wrist_frac * (tail_x - head_x)
cuff_start = head_x + wrist_frac * (1.0 - CUFF_FRAC) * (tail_x - head_x)
cut_planes.append((sign, wrist_thr, cuff_start))
log(f"wrist cut {bone_name}: keep |x| to {wrist_thr:.3f} "
f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {cuff_start:.3f}")
thr["wrist_planes"] = cut_planes
log(f"jacket thresholds: V top z {v_top:.3f} bottom z {v_bot:.3f} "
f"half-top {thr['v_half_top']:.3f} lapel {thr['lapel_w']:.3f} "
f"btn z {thr['jacket_btn_z']:.3f}")
return thr
# --------------------------------------------------------------------------
# Geometry: weld + wrist cut
# --------------------------------------------------------------------------
def weld_segment_boundaries(shell):
"""Merge coincident verts along segment seams (elbow/shoulder/chest lines).
Adjacent body segments duplicate their shared boundary loop; unwelded, the
two copies get island-local normals and offset APART, opening a gap ring at
every seam. Welding first makes the shell offset as one surface. Merged
verts come from the same original body vertex, so weights and loop UVs are
identical/preserved.
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST_M)
bm.to_mesh(shell.data)
after = len(shell.data.vertices)
bm.free()
shell.data.update()
log(f"welded segment boundaries: {before} -> {after} verts "
f"({before - after} merged)")
def wrist_cut(shell, thr):
"""Delete sleeve verts beyond the wrist plane on each lower arm."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = []
for v in bm.verts:
for sign, wrist_thr, _cuff in thr["wrist_planes"]:
if sign > 0 and v.co.x > wrist_thr:
to_delete.append(v)
break
if sign < 0 and v.co.x < wrist_thr:
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")
# --------------------------------------------------------------------------
# Combined per-pixel albedo paint + region mask bake
# --------------------------------------------------------------------------
def _classify_pixels(X, Y, Z, face_sleeve, face_side, thr):
"""Vectorized region + luma classification for one triangle's texels.
Returns (mask_rgba[N,4], luma[N]) for interpolated body-local positions.
Region priority: collar > shirt > lapel > body; sleeves are per-face.
"""
n = X.shape[0]
mask = np.zeros((n, 4), dtype=np.float32)
luma = np.full(n, LUMA_FABRIC, dtype=np.float32)
front = (Y * base.FRONT_Y_SIGN) > 0.010
ax = np.abs(X)
if face_sleeve:
mask[:, 2] = 1.0 # B = sleeves
# cuff band on this arm's outer end
for sign, wrist_thr, cuff_start in thr["wrist_planes"]:
if sign != face_side:
continue
in_cuff = (X * sign) >= (cuff_start * sign)
luma[in_cuff] = LUMA_CUFF
return mask, luma
v_top = thr["v_top_z"]
v_bot = thr["v_bot_z"]
# V half-width narrows linearly from v_half_top at the collar to 0 at v_bot.
t = np.clip((Z - v_bot) / max(v_top - v_bot, 1e-6), 0.0, None)
vhalf = thr["v_half_top"] * t
collar = (Z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"])
shirt = front & ~collar & (Z >= v_bot) & (Z < v_top) & (ax < vhalf)
lapel_out = vhalf + thr["lapel_w"]
lapel = (front & ~collar & ~shirt
& (Z >= v_bot - thr["lapel_drop"]) & (Z < v_top)
& (ax >= vhalf) & (ax < lapel_out))
mask[:, 0][collar | lapel] = 1.0 # R = lapels + collar band
mask[:, 3][shirt] = 1.0 # A = shirt triangle
body = ~(collar | shirt | lapel)
mask[:, 1][body] = 1.0 # G = jacket body
# ---- painted luma detail ----
luma[collar | lapel] = LUMA_SATIN
# lapel fold (inner) + edge (outer) shading lines
edge = lapel & ((ax < vhalf + thr["edge_w"]) | (ax > lapel_out - thr["edge_w"]))
luma[edge] = LUMA_EDGE
# shirt triangle: base, placket, buttons
luma[shirt] = LUMA_SHIRT
luma[shirt & (ax < thr["placket_half"])] = LUMA_PLACKET
for bz in thr["shirt_btn_zs"]:
btn = shirt & ((X ** 2 + (Z - bz) ** 2) < thr["shirt_btn_r"] ** 2)
luma[btn] = LUMA_SHIRT_BTN
# jacket closure seam + button below the V point
closure = body & front & (Z < v_bot) & (ax < thr["closure_half"])
luma[closure] = LUMA_CLOSURE
jbtn = front & ((X ** 2 + (Z - thr["jacket_btn_z"]) ** 2)
< thr["jacket_btn_r"] ** 2)
luma[jbtn] = LUMA_JACKET_BTN
return mask, luma
def bake_albedo_and_mask(shell, thr, albedo_path, mask_path, albedo_name):
"""One rasterization pass -> painted albedo PNG + RGBA region mask PNG.
Per texel the 3D body-local position is barycentric-interpolated, so paint
and regions are classified per PIXEL (crisp V edges on coarse topology).
Mask background = body green (bilinear bleed stays tinted); albedo
background = fabric luma.
"""
W = H = BAKE_SIZE
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
mask_buf[:, :, 1] = 1.0
albedo_buf = np.full((H, W), LUMA_FABRIC, dtype=np.float32)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for bake")
counts = {"collar_lapel": 0, "body": 0, "sleeve": 0, "shirt": 0}
for face in bm.faces:
center = face.calc_center_median()
face_sleeve = abs(center.x) >= thr["sleeve_x_abs"]
face_side = 1 if center.x >= 0.0 else -1
loops = face.loops[:]
uvs = [lo[uv_layer].uv for lo in loops]
cos = [lo.vert.co for lo in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_pos(
mask_buf, albedo_buf,
uvs[0], uvs[i], uvs[i + 1],
cos[0], cos[i], cos[i + 1],
face_sleeve, face_side, thr, W, H, counts)
bm.free()
log("bake texel classes: " + " ".join(f"{k}={v}" for k, v in counts.items()))
# ---- save mask ----
img = bpy.data.images.new("jacket_region_mask", W, H, alpha=True)
img.pixels.foreach_set(mask_buf.reshape(-1))
img.update()
img.filepath_raw = mask_path
img.file_format = 'PNG'
img.save()
log(f"baked region mask -> {mask_path}")
# ---- albedo: neutral grey luma + blocky woven noise, slightly cool ----
rng = np.random.default_rng(1089)
coarse = (rng.random((H // 4, W // 4), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
noise = np.kron(coarse, np.ones((4, 4), dtype=np.float32))
lum = np.clip(albedo_buf + noise, 0.0, 1.0)
rgba = np.empty((H, W, 4), dtype=np.float32)
for c, mul in enumerate(ALBEDO_TINT):
rgba[:, :, c] = np.clip(lum * mul, 0.0, 1.0)
rgba[:, :, 3] = 1.0
aimg = bpy.data.images.new(albedo_name, W, H, alpha=False)
aimg.pixels.foreach_set(rgba.reshape(-1))
aimg.update()
aimg.filepath_raw = albedo_path
aimg.file_format = 'PNG'
aimg.save()
log(f"painted albedo -> {albedo_path}")
return aimg
def _raster_tri_pos(mask_buf, albedo_buf, a, b, c, pa, pb, pc,
face_sleeve, face_side, thr, W, H, counts):
"""Barycentric fill interpolating 3D position for per-pixel classification."""
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
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
X = w0i * pa.x + w1i * pb.x + w2i * pc.x
Y = w0i * pa.y + w1i * pb.y + w2i * pc.y
Z = w0i * pa.z + w1i * pb.z + w2i * pc.z
mask_px, luma_px = _classify_pixels(
X.astype(np.float32), Y.astype(np.float32), Z.astype(np.float32),
face_sleeve, face_side, thr)
counts["collar_lapel"] += int((mask_px[:, 0] > 0.5).sum())
counts["shirt"] += int((mask_px[:, 3] > 0.5).sum())
counts["sleeve"] += int((mask_px[:, 2] > 0.5).sum())
counts["body"] += int((mask_px[:, 1] > 0.5).sum())
m_region = mask_buf[miny:maxy + 1, minx:maxx + 1, :]
a_region = albedo_buf[miny:maxy + 1, minx:maxx + 1]
m_region[inside] = mask_px
a_region[inside] = luma_px
# --------------------------------------------------------------------------
# Parked UV2 (suit is not logo-capable; keep mesh format pipeline-consistent)
# --------------------------------------------------------------------------
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("UV2 authored fully parked (non-logo garment)")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac):
base.clear_scene()
base.COVERED_SEGMENTS = JACKET_SEGMENTS
shell, armature = base.build_covered_mesh(body_dir)
thr = derive_jacket_thresholds(armature, wrist_frac)
weld_segment_boundaries(shell)
wrist_cut(shell, thr)
base.offset_outward(shell, offset)
base.solidify(shell, base.CLOTH_THICKNESS_M)
# Stage the painted albedo OUTSIDE the Godot project as "base_albedo.png"
# — the glTF image URI takes the file basename, so Godot's on-import
# extraction lands at <out_dir>/<body>_base_albedo.png (tshirt layout)
# without a duplicate authored sidecar next to it.
body_stage = os.path.join(stage_dir, body)
os.makedirs(body_stage, exist_ok=True)
albedo_img = bake_albedo_and_mask(
shell, thr,
os.path.join(body_stage, "base_albedo.png"),
os.path.join(out_dir, f"{body}_mask.png"),
f"jacket_albedo_{body}")
base.assign_fabric_material(shell, albedo_img)
author_parked_uv2(shell)
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-frac F]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
offset = JACKET_OFFSET_M
wrist_frac = WRIST_FRAC
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--wrist-frac" in argv:
wrist_frac = float(argv[argv.index("--wrist-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)
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
stage_dir = os.path.join(repo_root, ".cache", "garment-fit-suit")
log(f"jacket per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
f"wrist frac {wrist_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_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {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(stage_dir, 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 staged {ref} albedo -> base_albedo.png")
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()
@@ -0,0 +1,524 @@
"""
blender_author_lower_shell.py (T-1089, lower-body offset-shell companion)
Author LOWER-BODY offset-shell garments (the pants family) per body, reusing
blender_author_offset_shell.py as a library (scene build, offset, solidify,
raster helpers, export). The base script stays the torso/t-shirt reference;
this companion parameterizes the lower-body family so pants_formal, jeans,
shorts, joggers etc. are all CLI parameter sets over ONE code path — reusable
parameters over one-off hacks.
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r.
Natural boundaries give the waist opening (top of seg_hips) and ankle hems
(bottom of seg_leg_lower) for free; a leg cut is only applied for --leg-frac
< 1.0 (shorts family).
Region mask (RGBA, UV0-aligned — toon_garment.gdshader convention):
R = waistband (top band of the hips, height derived from the pelvis bone)
G = legs (everything else — the main fabric region)
B = cuffs (optional, --cuff-frac > 0; joggers)
Painted texture identity (baked into the per-body albedo, luma-carried so it
survives any region tint — the shader replaces hue but keeps luminance):
--crease subtle LIGHT front crease line down each leg (formal press line),
drawn by intersecting the shell with each leg's hip->knee->ankle
axis plane and rasterizing the crossing segments in UV space.
--seam subtle DARK horizontal seam line at the waistband boundary.
Per-body mode only (route guidance from Q-060 / 216-capture QA evidence:
offset-shell garments author per body; weights inherited by construction).
Usage:
tooling/blender --background --python \
tooling/garment-fit/blender_author_lower_shell.py -- \
<bodies_root> <out_dir> \
[--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \
[--waistband-frac 1.0] [--cuff-frac 0.0] \
[--fabric R,G,B] [--fabric-noise 0.012] \
[--no-crease] [--crease-gain 0.10] [--no-seam] [--seam-gain 0.05] \
[--seed 1093]
Writes per body:
<out_dir>/<body>.glb skinned garment authored on that body
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0 layout)
Plus:
<out_dir>/base_albedo.png reference body's albedo (sidecar; the
painted albedo is embedded per GLB, and
the Godot importer extracts it per body
as <body>_base_albedo.png)
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe).
"""
import os
import shutil
import sys
import bpy
import bmesh
import numpy as np
# Make the sibling base module importable inside Blender.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import blender_author_offset_shell as base # noqa: E402
# --------------------------------------------------------------------------
# Lower-body garment parameters (CLI-overridable)
# --------------------------------------------------------------------------
LOWER_SEGMENTS = [
"seg_hips",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
DEFAULTS = {
"offset": 0.010, # slim silhouette standoff (m); per-body authoring
# guarantees clearance by construction, and bottoms
# sit UNDER tops (t-shirt hem = 12 mm), so 10 mm
# keeps the layering order correct at the waist.
"leg_frac": 1.0, # fraction of hip->ankle length kept (1.0 = full)
"waistband_frac": 1.0, # waistband starts at pelvis head + frac*bone_len
"cuff_frac": 0.0, # B-region cuff height as fraction of knee->ankle
"fabric": (0.145, 0.147, 0.160), # dark charcoal, slight cool cast
"fabric_noise": 0.012, # +/- albedo jitter, subtle woven feel
"crease": True, # painted front crease line per leg
"crease_gain": 0.10, # luma lift along the crease (subtle press line)
"seam": True, # painted seam at the waistband boundary
"seam_gain": 0.05, # luma drop along the seam
"seed": 1093, # deterministic albedo noise
}
CREASE_TOP_MARGIN = 0.12 # crease starts this fraction below the hip joint
CREASE_NORMAL_MIN = 0.35 # face must point forward this much for the crease
LINE_RADIUS_PX = 0.9 # painted line half-width in texels
def log(msg):
print(f"[lower-shell] {msg}")
# --------------------------------------------------------------------------
# Threshold derivation (bone landmarks; the 11 bodies share the 65-bone rig)
# --------------------------------------------------------------------------
def derive_lower_thresholds(armature, p):
"""Derive waistband/cuff z-planes and per-leg axis polylines.
Anchors: pelvis (waistband band), thigh_* head (hip joint), thigh_* tail /
calf_* head (knee), calf_* tail (ankle). All in body-local metres so each
body derives its OWN proportionally-consistent parameters.
"""
bones = armature.data.bones
pelvis = bones.get("pelvis")
need = ["thigh_l", "thigh_r", "calf_l", "calf_r"]
legs = {}
for side in ("l", "r"):
thigh = bones.get(f"thigh_{side}")
calf = bones.get(f"calf_{side}")
if thigh is None or calf is None:
raise RuntimeError(f"landmark bones missing for leg _{side} ({need})")
# Polyline hip -> knee -> ankle in the XZ plane (rest pose, z-up).
legs[side] = {
"hip": (thigh.head_local.x, thigh.head_local.z),
"knee": (thigh.tail_local.x, thigh.tail_local.z),
"ankle": (calf.tail_local.x, calf.tail_local.z),
}
if pelvis is None:
raise RuntimeError("pelvis bone missing — cannot derive waistband")
pelvis_len = pelvis.tail_local.z - pelvis.head_local.z
wb_z = pelvis.head_local.z + p["waistband_frac"] * pelvis_len
hip_z = (legs["l"]["hip"][1] + legs["r"]["hip"][1]) / 2.0
ankle_z = (legs["l"]["ankle"][1] + legs["r"]["ankle"][1]) / 2.0
knee_z = (legs["l"]["knee"][1] + legs["r"]["knee"][1]) / 2.0
leg_len = hip_z - ankle_z
cut_z = None
if p["leg_frac"] < 0.999:
cut_z = hip_z - p["leg_frac"] * leg_len
cuff_z = None
if p["cuff_frac"] > 1e-6:
cuff_z = ankle_z + p["cuff_frac"] * (knee_z - ankle_z)
thr = {
"wb_z": wb_z,
"cut_z": cut_z,
"cuff_z": cuff_z,
"legs": legs,
"hip_z": hip_z,
"ankle_z": ankle_z,
"leg_len": leg_len,
}
log(f"thresholds: waistband z>={wb_z:.3f} "
f"cut_z={cut_z if cut_z is None else round(cut_z, 3)} "
f"cuff_z={cuff_z if cuff_z is None else round(cuff_z, 3)} "
f"hip_z={hip_z:.3f} ankle_z={ankle_z:.3f}")
return thr
def _leg_axis_x(leg, z):
"""Piecewise-linear x of the leg axis at height z (clamped to endpoints)."""
hx, hz = leg["hip"]
kx, kz = leg["knee"]
ax, az = leg["ankle"]
if z >= hz:
return hx
if z <= az:
return ax
if z >= kz:
t = (hz - z) / max(hz - kz, 1e-6)
return hx + t * (kx - hx)
t = (kz - z) / max(kz - az, 1e-6)
return kx + t * (ax - kx)
# --------------------------------------------------------------------------
# Seam weld (pre-offset)
# --------------------------------------------------------------------------
def weld_seams(shell):
"""Weld coincident verts before offsetting.
glTF import splits vertices along UV seams (and the joined segment
boundaries duplicate their shared rings), so offsetting each copy along
its OWN split normal tears the shell open — on the pants this showed as a
skin-coloured slit down the front-centre seam of the hips. Welding fuses
the copies so the offset moves one vertex along one averaged normal.
Per-loop UVs are untouched (the UV seam itself survives); coincident
copies carry identical bone weights by construction, so skinning is
unaffected.
"""
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=1e-4)
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"welded seams: {before} -> {len(shell.data.vertices)} verts")
# --------------------------------------------------------------------------
# Leg cut (shorts family; skipped at leg_frac 1.0)
# --------------------------------------------------------------------------
def leg_cut(shell, cut_z):
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
doomed = [v for v in bm.verts if v.co.z < cut_z]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"leg cut at z={cut_z:.3f}: removed {len(doomed)} verts; "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# Painted albedo (charcoal base + crease/seam lines, luma-carried identity)
# --------------------------------------------------------------------------
def _plane_crossings_uv(face, uv_layer, dist_fn):
"""UV points where the face's edge loop crosses dist_fn(co) == 0."""
loops = face.loops[:]
n = len(loops)
pts = []
for i in range(n):
la, lb = loops[i], loops[(i + 1) % n]
d1 = dist_fn(la.vert.co)
d2 = dist_fn(lb.vert.co)
if (d1 > 0.0) == (d2 > 0.0):
continue
denom = d1 - d2
if abs(denom) < 1e-9:
continue
t = d1 / denom
uv1 = la[uv_layer].uv
uv2 = lb[uv_layer].uv
pts.append((uv1[0] + t * (uv2[0] - uv1[0]),
uv1[1] + t * (uv2[1] - uv1[1])))
return pts
def _stamp_uv_line(hits, a, b, W, H):
"""Mark texels along UV segment a->b into boolean mask `hits`."""
ax, ay = a[0] * (W - 1), a[1] * (H - 1)
bx, by = b[0] * (W - 1), b[1] * (H - 1)
steps = int(max(abs(bx - ax), abs(by - ay)) * 2.0) + 1
r = LINE_RADIUS_PX
for i in range(steps + 1):
t = i / steps
px = ax + (bx - ax) * t
py = ay + (by - ay) * t
x0 = max(int(np.floor(px - r)), 0)
x1 = min(int(np.ceil(px + r)), W - 1)
y0 = max(int(np.floor(py - r)), 0)
y1 = min(int(np.ceil(py + r)), H - 1)
for yy in range(y0, y1 + 1):
for xx in range(x0, x1 + 1):
if (xx - px) ** 2 + (yy - py) ** 2 <= r * r + 0.25:
hits[yy, xx] = True
def bake_painted_albedo(shell, thr, p):
"""Charcoal fabric + painted crease/seam lines, baked in UV0 space.
The toon_garment shader keeps only albedo LUMINANCE under region tints, so
identity painted here (light crease, dark seam) survives any recolor.
"""
W = H = base.ALBEDO_SIZE
rng = np.random.default_rng(p["seed"])
fabric = np.array(p["fabric"], dtype=np.float32)
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * p["fabric_noise"]
rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
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 albedo bake")
crease_hits = np.zeros((H, W), dtype=bool)
seam_hits = np.zeros((H, W), dtype=bool)
crease_faces = 0
seam_faces = 0
crease_top = thr["hip_z"] - CREASE_TOP_MARGIN * thr["leg_len"]
for face in bm.faces:
center = face.calc_center_median()
# Waistband seam: any face crossing the wb_z plane (full ring).
if p["seam"]:
pts = _plane_crossings_uv(face, uv_layer,
lambda co: co.z - thr["wb_z"])
if len(pts) >= 2:
_stamp_uv_line(seam_hits, pts[0], pts[1], W, H)
seam_faces += 1
# Front crease: forward-facing faces crossing the leg-axis plane.
if p["crease"]:
if face.normal.y * base.FRONT_Y_SIGN < CREASE_NORMAL_MIN:
continue
if center.z > crease_top or center.z < thr["ankle_z"]:
continue
leg = thr["legs"]["l"] if center.x >= 0.0 else thr["legs"]["r"]
pts = _plane_crossings_uv(
face, uv_layer, lambda co: co.x - _leg_axis_x(leg, co.z))
if len(pts) >= 2:
_stamp_uv_line(crease_hits, pts[0], pts[1], W, H)
crease_faces += 1
bm.free()
if p["seam"]:
rgb[seam_hits, :] = np.clip(rgb[seam_hits, :] - p["seam_gain"], 0.0, 1.0)
if p["crease"]:
rgb[crease_hits, :] = np.clip(rgb[crease_hits, :] + p["crease_gain"], 0.0, 1.0)
log(f"painted albedo: crease faces={crease_faces} "
f"({int(crease_hits.sum())} px) seam faces={seam_faces} "
f"({int(seam_hits.sum())} px)")
if p["crease"] and crease_faces == 0:
log("WARNING: crease painted on 0 faces — check leg axis / front sign")
rgba = np.concatenate(
[rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
img = bpy.data.images.new("garment_lower_albedo", W, H, alpha=False)
img.pixels.foreach_set(rgba.reshape(-1))
img.update()
return img
# --------------------------------------------------------------------------
# Region mask (waistband R / legs G / optional cuff B)
# --------------------------------------------------------------------------
def _classify_lower(center, thr):
if center.z >= thr["wb_z"]:
return (1.0, 0.0, 0.0, 0.0) # waistband -> R (tint_0)
if thr["cuff_z"] is not None and center.z <= thr["cuff_z"]:
return (0.0, 0.0, 1.0, 0.0) # cuff -> B (tint_2)
return (0.0, 1.0, 0.0, 0.0) # legs -> G (tint_1)
def bake_lower_mask(shell, out_path, thr):
W = H = base.MASK_SIZE
buf = np.zeros((H, W, 4), dtype=np.float32)
buf[:, :, 1] = 1.0 # green background = legs (main region)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for region mask bake")
counts = {"waistband": 0, "legs": 0, "cuff": 0}
for face in bm.faces:
color = _classify_lower(face.calc_center_median(), thr)
if color[0] > 0.5:
counts["waistband"] += 1
elif color[2] > 0.5:
counts["cuff"] += 1
else:
counts["legs"] += 1
for a, b, c in base._tris_from_face(face, uv_layer):
base._raster_tri(buf, a, b, c, color, 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()))
if counts["waistband"] == 0:
log("WARNING: waistband region empty — check waistband_frac / pelvis")
img = bpy.data.images.new("garment_lower_mask", W, H, alpha=True)
img.pixels.foreach_set(buf.reshape(-1))
img.update()
img.filepath_raw = out_path
img.file_format = 'PNG'
img.save()
log(f"baked region mask -> {out_path}")
# --------------------------------------------------------------------------
# Parked logo UV2 (pants are not logo-capable; keep the channel guarded)
# --------------------------------------------------------------------------
def author_parked_logo_uv(shell):
me = shell.data
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
logo_uv = me.uv_layers.new(name="logo_uv")
me.uv_layers.active = me.uv_layers[0]
data = logo_uv.data
parked = [2.0, 2.0] * len(data)
logo_uv.data.foreach_set("uv", parked)
me.update()
log(f"logo UV2 parked outside [0,1] on all {len(data)} loops")
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_lower_shell(body_dir, out_dir, body, p):
base.clear_scene()
base.COVERED_SEGMENTS = LOWER_SEGMENTS # reuse the base segment importer
shell, armature = base.build_covered_mesh(body_dir)
thr = derive_lower_thresholds(armature, p)
zs = [v.co.z for v in shell.data.vertices]
log(f"shell z-range: {min(zs):.3f}..{max(zs):.3f}")
if thr["wb_z"] >= max(zs):
log("WARNING: waistband plane above shell top — band will be empty")
weld_seams(shell)
if thr["cut_z"] is not None:
leg_cut(shell, thr["cut_z"])
base.offset_outward(shell, p["offset"])
base.solidify(shell, base.CLOTH_THICKNESS_M)
albedo_img = bake_painted_albedo(shell, thr, p)
base.assign_fabric_material(shell, albedo_img)
author_parked_logo_uv(shell)
bake_lower_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr)
# Save under the SHARED sidecar name for every body: the glTF exporter
# names the embedded image after this filepath, and the Godot importer
# extracts embedded textures as <glb>_<imagename>.png — so this yields the
# per-body <body>_base_albedo.png convention (as tshirt_modern) on import.
# The loop authors the reference body LAST so base_albedo.png ends up as
# the reference albedo (per-body albedos differ: painted crease follows
# each body's own geometry).
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"))
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def parse_args(argv):
p = dict(DEFAULTS)
# Reference body last: the shared base_albedo.png sidecar is rewritten per
# body, so ordering makes the surviving copy the reference body's.
bodies = [b for b in base.BODY_TYPES if b != base.REFERENCE_BODY]
bodies.append(base.REFERENCE_BODY)
def _val(flag):
return argv[argv.index(flag) + 1]
if "--bodies" in argv:
bodies = [s.strip() for s in _val("--bodies").split(",")]
for flag, key, cast in [
("--offset", "offset", float),
("--leg-frac", "leg_frac", float),
("--waistband-frac", "waistband_frac", float),
("--cuff-frac", "cuff_frac", float),
("--fabric-noise", "fabric_noise", float),
("--crease-gain", "crease_gain", float),
("--seam-gain", "seam_gain", float),
("--seed", "seed", int),
]:
if flag in argv:
p[key] = cast(_val(flag))
if "--fabric" in argv:
p["fabric"] = tuple(float(s) for s in _val("--fabric").split(","))
if "--no-crease" in argv:
p["crease"] = False
if "--no-seam" in argv:
p["seam"] = False
return p, bodies
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print(__doc__)
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
p, bodies = parse_args(argv)
os.makedirs(out_dir, exist_ok=True)
log(f"lower-shell per-body: {len(bodies)} bodies, offset "
f"{p['offset']*1000:.0f} mm, leg_frac {p['leg_frac']}, "
f"fabric {p['fabric']}")
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_lower_shell(body_dir, out_dir, body, p)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
results.append((body, f"error: {exc}"))
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(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png")
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,533 @@
"""
blender_author_offset_coverall.py (T-1089, uniform_utility — route (c) proof)
Full-body COVERALL authored via the offset-shell technique, per body. Companion
to blender_author_offset_shell.py (imported as a module): reuses its scene
plumbing, offset/solidify, logo-UV2 authoring, rasterizer and export — and
extends it with the coverall-specific geometry and the four-region utility
mask that this garment exists to prove:
ONE garment covering torso + torso_upper + arms + hips + legs. The upper and
lower shells join at the waist for free: adjacent body segments duplicate a
coincident overlap band (probe: median nearest-vertex distance 0.0 in every
seam band), so the joined mesh has no gap by construction and the duplicated
faces offset identically (same verts, same normals, same atlas UVs) and
render invisibly.
Geometry beyond the base script (parameterised, not hard-coded):
- WRIST cut: trim lowerarm tubes at a fraction along the lowerarm bone
(full sleeve ending in a cuff above the hand).
- ANKLE cut: trim calf tubes at a fraction along the calf bone (leg ends in
a cuff above the boot line; feet stay free for footwear garments).
Both are bone-landmark fractions, so every body derives its own planes.
Region mask (the multi-region showcase, R/G/B/A -> tint_0..3):
G = main body fabric
R = trim: collar band + arm cuffs + leg cuffs
B = belt line (waist band, hides the segment seam) + chest patch (logo box,
logo-capable) + right-thigh utility patch
A = shoulder marks (top-facing epaulette strap on each shoulder)
Painted albedo details (flat, toon-friendly; identity lives in the texture):
per-region flat luminance fills (dark belt webbing, bright patches, mid
shoulder marks), a brighter buckle plate at the front-centre of the belt,
and dark stitch lines rasterised along every region-boundary edge (patch
borders, collar seam, cuff seams, belt edges). The toon_garment shader is
luminance-preserving, so these survive any region recolor.
Per-body only (this garment ships per-body per the Q-060 route guidance):
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_coverall.py -- \
<bodies_root> <out_dir> [--bodies a,b,c] [--offset 0.012]
Writes per body:
<out_dir>/<body>.glb skinned coverall authored on that body
<out_dir>/<body>_mask.png RGBA region mask (that body's atlas UVs)
<out_dir>/<body>_base_albedo.png painted detail albedo (also in the GLB)
Plus:
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
<out_dir>/base_albedo.png copy of average_m's albedo (convention)
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
(per-body authoring for offset shells).
"""
import sys
import os
import shutil
import bpy
import bmesh
import numpy as np
# Make the sibling base module 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
# --------------------------------------------------------------------------
# Coverall parameters (all bone-landmark fractions unless noted)
# --------------------------------------------------------------------------
COVERED_SEGMENTS = [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
"seg_hips",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
]
WRIST_CUT_FRAC = 0.88 # keep this fraction of the lowerarm (elbow->wrist)
ANKLE_CUT_FRAC = 0.82 # keep this fraction of the calf (knee->ankle)
CUFF_ARM_START_FRAC = 0.60 # cuff band = beyond this fraction of the lowerarm
CUFF_LEG_START_FRAC = 0.56 # cuff band = beyond this fraction of the calf
# Belt: centred between pelvis head and spine_01 head (the torso|hips seam
# band sits at ~0.90 of that span on average_m), half-height in ref metres
# scaled by each body's pelvis->spine_01 span.
BELT_CENTER_FRAC = 0.90
BELT_HALF_M_REF = 0.032
BUCKLE_X_ABS_REF = 0.045 # front-centre belt faces within this |x| = buckle plate
# Shoulder marks: top-facing band above the upperarm head, outside the collar.
SHOULDER_Z_FRAC = 0.18 # of (neck head z - upperarm head z), above upperarm head
SHOULDER_X_MAX_FRAC = 1.38 # of shoulder |x|
SHOULDER_NORMAL_Z_MIN = 0.45 # surface must point upward
# Chest patch: the logo box inset by this fraction of its width/height per
# side, so the amber patch frames the decal instead of touching the box edge.
CHEST_PATCH_INSET_FRAC = 0.10
# Right-thigh utility patch: box along the thigh bone, front-facing.
THIGH_PATCH_SIDE = "thigh_r"
THIGH_PATCH_Z_FRACS = (0.30, 0.58) # fraction down the thigh bone
THIGH_PATCH_HALF_X_REF = 0.058
THIGH_PATCH_NORMAL_Y_MIN = 0.10 # forward-facing (front = -Y, base.FRONT_Y_SIGN)
# Painted-albedo luminance per region label (flat toon fills).
ALBEDO_LUMA = {
"body": 0.62,
"collar": 0.66,
"cuff": 0.66,
"shoulder": 0.50,
"belt": 0.34,
"buckle": 0.82,
"patch": 0.72,
}
STITCH_LUMA = 0.30 # dark seam/stitch lines on region boundaries
ALBEDO_NOISE = 0.03 # +/- woven-feel jitter (matches the base script)
# Label ids index the vectorised classifier's output arrays.
LABELS = ["body", "collar", "cuff", "shoulder", "belt", "buckle", "patch"]
LABEL_ID = {name: i for i, name in enumerate(LABELS)}
LABEL_RGBA = {
"collar": (1.0, 0.0, 0.0, 0.0), # R trim
"cuff": (1.0, 0.0, 0.0, 0.0), # R trim
"body": (0.0, 1.0, 0.0, 0.0), # G main fabric
"belt": (0.0, 0.0, 1.0, 0.0), # B belt + patches
"buckle": (0.0, 0.0, 1.0, 0.0), # B
"patch": (0.0, 0.0, 1.0, 0.0), # B
"shoulder": (0.0, 0.0, 0.0, 1.0), # A shoulder marks
}
LABEL_RGBA_ARR = np.array([LABEL_RGBA[n] for n in LABELS], 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)
log = base.log
# --------------------------------------------------------------------------
# Threshold derivation (extends base.derive_thresholds with coverall zones)
# --------------------------------------------------------------------------
def derive_coverall_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 = bone("upperarm_l")
neck = bone("neck_01")
pelvis = bone("pelvis")
spine01 = bone("spine_01")
shoulder_x = abs(ua.head_local.x)
scale = shoulder_x / _REF_SHOULDER_X
# Wrist cut planes + arm cuff start, per side (arm runs along +/-X).
for side, sign in (("l", +1), ("r", -1)):
la = bone(f"lowerarm_{side}")
hx, tx = la.head_local.x, la.tail_local.x
thr[f"wrist_cut_x_{side}"] = hx + WRIST_CUT_FRAC * (tx - hx)
thr[f"cuff_arm_x_{side}"] = hx + CUFF_ARM_START_FRAC * (tx - hx)
# Ankle cut + leg cuff start (leg runs down -Z; both calves share z).
calf = bone("calf_l")
hz, tz = calf.head_local.z, calf.tail_local.z
thr["ankle_cut_z"] = hz + ANKLE_CUT_FRAC * (tz - hz)
thr["cuff_leg_z"] = hz + CUFF_LEG_START_FRAC * (tz - hz)
# Belt band.
pz, sz = pelvis.head_local.z, spine01.head_local.z
span = sz - pz
thr["belt_z"] = pz + BELT_CENTER_FRAC * span
thr["belt_half"] = BELT_HALF_M_REF * scale
thr["buckle_x_abs"] = BUCKLE_X_ABS_REF * scale
# Shoulder marks.
uz = ua.head_local.z
thr["shoulder_z_min"] = uz + SHOULDER_Z_FRAC * (neck.head_local.z - uz)
thr["shoulder_x_max"] = shoulder_x * SHOULDER_X_MAX_FRAC
# Chest patch = logo box inset a little on every side.
cx0, cx1 = thr["chest_x"]
cz0, cz1 = thr["chest_z"]
dx = (cx1 - cx0) * CHEST_PATCH_INSET_FRAC
dz = (cz1 - cz0) * CHEST_PATCH_INSET_FRAC
thr["patch_x"] = (cx0 + dx, cx1 - dx)
thr["patch_z"] = (cz0 + dz, cz1 - dz)
# Right-thigh patch box.
thigh = bone(THIGH_PATCH_SIDE)
thz, ttz = thigh.head_local.z, thigh.tail_local.z
f0, f1 = THIGH_PATCH_Z_FRACS
thr["thigh_patch_z"] = (thz + f1 * (ttz - thz), thz + f0 * (ttz - thz))
tx_ctr = thigh.head_local.x
half = THIGH_PATCH_HALF_X_REF * scale
thr["thigh_patch_x"] = (tx_ctr - half, tx_ctr + half)
log(
"coverall thresholds: "
f"wrist_l x>{thr['wrist_cut_x_l']:.3f} wrist_r x<{thr['wrist_cut_x_r']:.3f} "
f"ankle z<{thr['ankle_cut_z']:.3f} "
f"belt z={thr['belt_z']:.3f}+/-{thr['belt_half']:.3f} "
f"shoulder z>={thr['shoulder_z_min']:.3f} "
f"thigh_patch x=({thr['thigh_patch_x'][0]:.3f},{thr['thigh_patch_x'][1]:.3f}) "
f"z=({thr['thigh_patch_z'][0]:.3f},{thr['thigh_patch_z'][1]:.3f})"
)
return thr
# --------------------------------------------------------------------------
# Limb cuts (wrists + ankles)
# --------------------------------------------------------------------------
def limb_cuts(shell, thr):
"""Delete verts beyond the wrist planes (|X|) and below the ankle plane (Z)."""
wl = thr["wrist_cut_x_l"]
wr = thr["wrist_cut_x_r"]
az = thr["ankle_cut_z"]
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = [
v for v in bm.verts
if v.co.x > wl or v.co.x < wr or v.co.z < az
]
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"limb cuts removed {len(to_delete)} verts "
f"(wrist x>{wl:.3f}/x<{wr:.3f}, ankle z<{az:.3f}); "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# Region classification (vectorised, per texel)
#
# Face-granular classification made the patch/belt/shoulder boundaries follow
# the triangulation (jagged, torn-looking zones on the first preview). The
# bake therefore classifies every TEXEL: barycentric interpolation gives each
# covered texel a body-space position + smoothed vertex normal, and the zone
# boundaries land exactly where the thresholds say — crisp at mask resolution.
# --------------------------------------------------------------------------
def classify_texels(pos, nrm, thr):
"""Classify N texels. pos/nrm are (N,3) body-local arrays.
Returns (N,) uint8 label ids (indices into LABELS). Precedence: collar,
cuffs, shoulder marks, belt/buckle, chest patch, thigh patch, body.
"""
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
fy = y * base.FRONT_Y_SIGN
fwd = nrm[:, 1] * base.FRONT_Y_SIGN
ax = np.abs(x)
lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8)
remaining = np.ones(x.shape, dtype=bool)
def take(cond, name):
m = cond & remaining
lab[m] = LABEL_ID[name]
remaining[m] = False
take((z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"]), "collar")
take((x >= thr["cuff_arm_x_l"]) | (x <= thr["cuff_arm_x_r"]), "cuff")
take(z <= thr["cuff_leg_z"], "cuff")
take(
(z >= thr["shoulder_z_min"])
& (ax >= thr["collar_x_abs"]) & (ax <= thr["shoulder_x_max"])
& (nrm[:, 2] > SHOULDER_NORMAL_Z_MIN),
"shoulder",
)
belt = np.abs(z - thr["belt_z"]) <= thr["belt_half"]
take(belt & (fy > 0.0) & (ax < thr["buckle_x_abs"]), "buckle")
take(belt, "belt")
px0, px1 = thr["patch_x"]
pz0, pz1 = thr["patch_z"]
take(
(fy > base.CHEST_FRONT_Y)
& (x >= px0) & (x <= px1) & (z >= pz0) & (z <= pz1)
& (fwd > base.CHEST_NORMAL_Y),
"patch",
)
tx0, tx1 = thr["thigh_patch_x"]
tz0, tz1 = thr["thigh_patch_z"]
take(
(fy > 0.0)
& (x >= tx0) & (x <= tx1) & (z >= tz0) & (z <= tz1)
& (fwd > THIGH_PATCH_NORMAL_Y_MIN),
"patch",
)
return lab
# --------------------------------------------------------------------------
# Combined mask + painted-albedo bake (one classification pass)
# --------------------------------------------------------------------------
def _tri_texels(a, b, c, W, H):
"""Texels covered by UV triangle (a,b,c) with barycentric weights.
Same maths as base._raster_tri, but returns (ys, xs, w0, w1, w2) arrays
instead of writing a flat colour, so the caller can interpolate per-texel
attributes (position, normal) across the triangle.
"""
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]
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 empty
return (
ys[inside], xs[inside],
w0[inside].astype(np.float32),
w1[inside].astype(np.float32),
w2[inside].astype(np.float32),
)
def bake_mask_and_albedo(shell, thr, mask_path, albedo_name, seed):
"""One pass over the faces: bake the RGBA region mask AND the painted
detail albedo (flat per-region luminance + stitch lines + woven noise).
Classification is per TEXEL (interpolated position + smoothed vertex
normal), so zone boundaries are crisp at mask resolution instead of
following the triangulation.
"""
W = H = base.MASK_SIZE
mask = np.zeros((H, W, 4), dtype=np.float32)
mask[:, :, 1] = 1.0 # green background = main 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 = _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, thr)
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]
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: texel-space label transitions where BOTH texels belong to
# rasterised geometry (skipping UV-island borders against background).
# Buckle|belt stays seamless (same physical strap).
lm = np.where(label_map == LABEL_ID["buckle"], LABEL_ID["belt"], label_map)
edge = np.zeros((H, W), dtype=bool)
dh = (lm[:, 1:] != lm[:, :-1]) & covered[:, 1:] & covered[:, :-1]
edge[:, 1:] |= dh
edge[:, :-1] |= dh
dv = (lm[1:, :] != lm[:-1, :]) & covered[1:, :] & covered[:-1, :]
edge[1:, :] |= dv
edge[:-1, :] |= dv
albedo[edge, 0:3] = STITCH_LUMA
bm.free()
log(f"stitch lines on {int(edge.sum())} boundary texels")
# Floor the alpha channel at 2/255: Godot's default texture import runs
# process/fix_alpha_border, which rewrites the RGB of fully-transparent
# texels bordering opaque ones — that would smear the shoulder-mark island
# edges into the surrounding body-green weights. With no alpha-0 texels the
# pass is a no-op; the 0.8% tint_3 weight it adds everywhere is invisible.
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True)
# The mask is channel-packed DATA (R/G/B/A region weights), not imagery
# with transparency: without this, Blender's straight-alpha PNG save
# zeroes the A channel (verified: shoulder-mark texels came back A=0).
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
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_coverall(body_dir, out_dir, body, offset, seed):
base.clear_scene()
base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this
shell, armature = base.build_covered_mesh(body_dir)
thr = derive_coverall_thresholds(armature)
limb_cuts(shell, thr)
base.offset_outward(shell, offset)
# Author UV2 + bake mask/albedo BEFORE solidify: the inner shell that
# solidify adds duplicates every face with the SAME atlas UVs but a
# flipped normal — the normal-gated rules (shoulder marks, patches) would
# classify those copies as body and overwrite the very texels the outer
# faces just wrote. Pre-solidify there is exactly one face per texel.
base.author_logo_uv(shell, thr) # UV2 (inner shell inherits it, harmless)
albedo_img = bake_mask_and_albedo(
shell, thr, 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)
# Save the albedo under the SHARED name before export: the glTF exporter
# names the embedded texture after the image filepath basename, and the
# Godot GLB import extracts it as <glb>_<texname>.png — with the shared
# name that lands on the tshirt-convention <body>_base_albedo.png (a
# <body>-specific filepath here would yield <body>_<body>_base_albedo.png).
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"))
# Keep a deterministic authored per-body sidecar as well.
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"coverall per-body mode: {len(bodies)} bodies, 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_coverall(body_dir, out_dir, body, offset, seed=1089 + 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: reference_mask.png + base_albedo.png mirror 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(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
# base_albedo.png currently holds the LAST body's albedo; restore the
# reference body's copy so the shared sidecar is deterministic.
ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
if os.path.isfile(ref_albedo):
shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
log(f"copied {base.REFERENCE_BODY}_base_albedo.png -> base_albedo.png")
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,887 @@
"""
blender_author_offset_shell.py (T-1089, route (c) offset-shell authoring)
Derive a garment SHELL from our own body segment meshes — the "create-stuff-
yourself" authoring pipeline that owes nothing to any vendor pack. Because the
shell IS our body topology, bone weights are inherited by construction (no
Surface-Deform, no Data-Transfer, no re-rig): every vertex keeps the 65-bone
vertex groups it had as skin.
Pipeline (t-shirt reference on average_m):
1. Import the body segments the garment covers (torso + torso_upper + upper
arms), keep only the skinned body meshes (Icosphere debris filtered out).
2. Join into one mesh under a single armature; merge vertex groups by name.
3. Bone-plane SLEEVE cut — trim the upper-arm tube to short-sleeve length via
a coordinate threshold derived from the upperarm bone axis (robust; no
boundary-loop classification, which the segment tool warns is fragile).
Neckline + hem come free as the natural segment boundaries.
4. Offset the surface outward along vertex normals (~12 mm standoff from skin).
5. Solidify (use_rim=True) — gives the cloth real thickness and caps the cut
rims (sleeve openings) into hems.
6. Assign ONE flat modern-fabric material (drops all skin textures). Style pin:
neutral heather tone, no trim, no fantasy anything.
7. Bake a UV0-aligned RGBA REGION MASK per-face: collar band -> R, main body
-> G, sleeve trim -> B (channel-routed 4-tint shader input, G3).
8. Author a 2nd UV channel (TEXCOORD_1) projecting the front chest into [0,1]
for the logo decal (G4); everything else parks outside the box.
9. Export the reference GLB (export_skins=True) + write reference_mask.png and
base_albedo.png sidecars.
Two modes (T-1089):
SINGLE-REFERENCE (default) — author on one body (average_m); G1
(blender_batch_fit_skinned.py) SD-fits it to the other body types. Right for
derived/hand-authored garments that share one UV layout + one mask.
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell.py -- \
<bodies_dir>/average_m <out_dir> [--offset 0.020] [--sleeve-frac 0.40]
Writes:
<out_dir>/average_m.glb reference garment (skinned)
<out_dir>/reference_mask.png RGBA region mask (UV0-aligned)
<out_dir>/base_albedo.png flat fabric albedo (also embedded in the GLB)
PER-BODY (--per-body) — author the shell from EACH body's own segment meshes.
QA evidence (Q-060): single-reference SD-fit of offset-shells degrades with
girth divergence (muscular_m 859px worst clip at 24 mm standoff). Authoring
per body gives guaranteed standoff + exact weights by construction, and lets
the offset drop back to the ~12 mm ideal. Cut/mask parameters are derived
from each body's OWN bone landmarks using the same proportional ratios
(anchored to reproduce the hand-calibrated average_m constants exactly), so
regions stay consistent across bodies.
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell.py -- \
<bodies_dir> <out_dir> --per-body \
[--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40]
Writes per body:
<out_dir>/<body>.glb skinned garment authored on that body
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0 layout)
Plus:
<out_dir>/base_albedo.png shared flat fabric albedo (deterministic)
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
The runtime compositor (character_visual.gd) prefers <body>_mask.png and
falls back to reference_mask.png for SD-fit garments.
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
"""
import sys
import os
import shutil
import bpy
import bmesh
import numpy as np
# --------------------------------------------------------------------------
# Parameters
# --------------------------------------------------------------------------
# Segments a t-shirt covers. Order matters only for join-active choice.
COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r"]
OFFSET_M = 0.020 # single-reference standoff along vertex normals; larger
# than the 10-14 mm ideal on the reference body buys
# clearance for bigger bodies under Surface-Deform
# batch-fit (Q-060) — the muscular/female torso otherwise
# pokes through.
PER_BODY_OFFSET_M = 0.012 # per-body standoff — each body's own surface guarantees
# clearance by construction, so the ideal applies.
CLOTH_THICKNESS_M = 0.004 # Solidify thickness after offset
SLEEVE_FRAC = 0.40 # fraction of upper-arm length kept (short sleeve)
MASK_SIZE = 512
ALBEDO_SIZE = 512
# All shipping body types (matches blender_batch_fit_skinned.py / the runtime).
BODY_TYPES = [
"average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f",
"heavy_m", "heavy_f", "teen_m", "teen_f", "child",
]
REFERENCE_BODY = "average_m"
# Flat modern-fabric base tone (linear-ish sRGB), neutral heather grey.
FABRIC_RGB = (0.60, 0.61, 0.63)
FABRIC_NOISE = 0.03 # +/- albedo jitter for a subtle woven feel
# Chest logo box in body-local metres (X width, Z height).
# FRONT AXIS: these Quaternius bodies face -Y in Blender (verified empirically —
# a +Y test projection landed on the character's back). So "front" = -Y.
FRONT_Y_SIGN = -1.0
CHEST_X = (-0.12, 0.12)
CHEST_Z = (1.16, 1.44)
CHEST_FRONT_Y = 0.015 # face centre must be on the front side by at least this
CHEST_NORMAL_Y = 0.20 # face normal must point forward by at least this much
# Region-mask classification (body-local, Z up).
COLLAR_Z_MIN = 1.49 # faces above this AND near centre -> collar band (R)
COLLAR_X_ABS = 0.11 # collar band stays near the neck, not the shoulders
SLEEVE_X_ABS = 0.20 # faces with |center X| beyond this -> sleeve cap (B)
# --------------------------------------------------------------------------
# Per-body threshold derivation (T-1089 per-body shell mode)
#
# The absolute constants above were hand-calibrated on average_m. The ratios
# below re-express every one of them against average_m's bone landmarks
# (shoulder = upperarm head |x| 0.1919, neck_01 head z 1.5205 / length 0.0793,
# spine_01 head z 1.072) so any body derives the SAME proportional cut/mask
# parameters from its own armature. On average_m the derivation reproduces
# the legacy constants exactly; the 11 bodies share one 65-bone rig, so the
# landmarks exist everywhere.
# --------------------------------------------------------------------------
_REF_SHOULDER_X = 0.1919
_REF_NECK_Z = 1.5205
_REF_NECK_LEN = 0.0793
_REF_SPINE_LO_Z = 1.072
SLEEVE_X_FRAC = SLEEVE_X_ABS / _REF_SHOULDER_X # of shoulder |x|
COLLAR_X_FRAC = COLLAR_X_ABS / _REF_SHOULDER_X # of shoulder |x|
COLLAR_DROP_FRAC = (_REF_NECK_Z - COLLAR_Z_MIN) / _REF_NECK_LEN # below neck head
CHEST_X_FRAC = CHEST_X[1] / _REF_SHOULDER_X # of shoulder |x|
_REF_SPINE_SPAN = _REF_NECK_Z - _REF_SPINE_LO_Z
CHEST_Z_LO_FRAC = (CHEST_Z[0] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN
CHEST_Z_HI_FRAC = (CHEST_Z[1] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN
def log(msg):
print(f"[offset-shell] {msg}")
def derive_thresholds(armature):
"""Derive cut/mask thresholds from this body's bone landmarks.
Returns a dict {sleeve_x_abs, collar_z_min, collar_x_abs, chest_x, chest_z}.
Falls back to the legacy average_m constants when landmarks are missing.
"""
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]):
log("WARNING: landmark bones missing — using legacy average_m thresholds")
return {
"sleeve_x_abs": SLEEVE_X_ABS,
"collar_z_min": COLLAR_Z_MIN,
"collar_x_abs": COLLAR_X_ABS,
"chest_x": CHEST_X,
"chest_z": CHEST_Z,
}
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
neck_z = neck.head_local.z
neck_len = neck.tail_local.z - neck.head_local.z
spine_lo = spine01.head_local.z
spine_span = neck_z - spine_lo
thr = {
"sleeve_x_abs": shoulder_x * SLEEVE_X_FRAC,
"collar_z_min": neck_z - COLLAR_DROP_FRAC * neck_len,
"collar_x_abs": shoulder_x * COLLAR_X_FRAC,
"chest_x": (-shoulder_x * CHEST_X_FRAC, shoulder_x * CHEST_X_FRAC),
"chest_z": (spine_lo + CHEST_Z_LO_FRAC * spine_span,
spine_lo + CHEST_Z_HI_FRAC * spine_span),
}
log(f"thresholds: sleeve |x|>={thr['sleeve_x_abs']:.3f} "
f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_x_abs']:.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
# --------------------------------------------------------------------------
# Scene helpers
# --------------------------------------------------------------------------
def clear_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
bpy.ops.outliner.orphans_purge(do_recursive=True)
def import_glb(path):
before = set(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=path)
return [o for o in bpy.context.scene.objects if o not in before]
def is_body_mesh(obj):
"""A real skinned body segment mesh — not Icosphere debris."""
if obj.type != 'MESH':
return False
if obj.name.startswith("Icosphere"):
return False
if len(obj.vertex_groups) == 0:
return False
if len(obj.data.vertices) < 50:
return False
return True
# --------------------------------------------------------------------------
# Build the joined shell base
# --------------------------------------------------------------------------
def build_covered_mesh(body_dir):
"""Import covered segments, keep skinned meshes, join to one mesh + armature."""
body_meshes = []
armature = None
for seg in COVERED_SEGMENTS:
path = os.path.join(body_dir, f"{seg}.glb")
if not os.path.isfile(path):
log(f"WARNING: missing segment {path} — skipping")
continue
objs = import_glb(path)
for o in objs:
if o.type == 'ARMATURE' and armature is None:
armature = o
elif o.type == 'ARMATURE':
# drop extra armature copies (identical rest pose)
bpy.data.objects.remove(o, do_unlink=True)
elif is_body_mesh(o):
body_meshes.append(o)
else:
# Icosphere / debris
bpy.data.objects.remove(o, do_unlink=True)
if not body_meshes:
raise RuntimeError("no skinned body meshes imported for covered segments")
if armature is None:
raise RuntimeError("no armature found in covered segments")
# Join meshes (vertex groups merge by name across segments).
bpy.ops.object.select_all(action='DESELECT')
for m in body_meshes:
m.select_set(True)
bpy.context.view_layer.objects.active = body_meshes[0]
bpy.ops.object.join()
shell = bpy.context.active_object
shell.name = "garment_shell"
# Re-point the armature modifier at the surviving armature; re-parent.
for mod in list(shell.modifiers):
if mod.type == 'ARMATURE':
mod.object = armature
shell.parent = armature
shell.matrix_parent_inverse = armature.matrix_world.inverted()
log(f"joined shell: {len(shell.data.vertices)} verts, "
f"{len(shell.data.polygons)} faces, {len(shell.vertex_groups)} vgroups")
return shell, armature
# --------------------------------------------------------------------------
# Bone-plane sleeve cut
# --------------------------------------------------------------------------
def sleeve_cut(shell, armature):
"""Delete sleeve-tip verts beyond the short-sleeve plane on each upper arm.
The upper arm runs along +/-X (shoulder head -> elbow tail). We keep the
fraction SLEEVE_FRAC of that length from the shoulder and delete the rest.
Torso verts stay (|X| < shoulder head), so a single coordinate threshold is
safe and needs no per-vertex weight test.
"""
bones = armature.data.bones
cut_planes = [] # (axis_sign, threshold_x)
for bone_name, sign in [("upperarm_l", +1), ("upperarm_r", -1)]:
b = bones.get(bone_name)
if b is None:
log(f"WARNING: bone {bone_name} missing — sleeve not cut on that side")
continue
head_x = b.head_local.x
tail_x = b.tail_local.x
thr = head_x + SLEEVE_FRAC * (tail_x - head_x)
cut_planes.append((sign, thr))
log(f"sleeve cut {bone_name}: keep |x| up to {thr:.3f} "
f"(shoulder {head_x:.3f} -> elbow {tail_x:.3f})")
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = []
for v in bm.verts:
for sign, thr in cut_planes:
if sign > 0 and v.co.x > thr:
to_delete.append(v)
break
if sign < 0 and v.co.x < thr:
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"sleeve cut removed {len(to_delete)} verts; "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# Outward offset + solidify
# --------------------------------------------------------------------------
def offset_outward(shell, offset):
"""Push every vertex outward along its (smoothed) normal by `offset` m."""
me = shell.data
me.calc_normals_split() if hasattr(me, "calc_normals_split") else None
bm = bmesh.new()
bm.from_mesh(me)
bm.normal_update()
for v in bm.verts:
v.co += v.normal * offset
bm.to_mesh(me)
bm.free()
me.update()
log(f"offset surface outward by {offset*1000:.0f} mm along normals")
# --------------------------------------------------------------------------
# Convex toe box (T-1089 footwear fix — shared by sneakers/shoes/boots)
#
# Closed shoes have a smooth rigid TOE BOX: a convex rounded cap the toes sit
# INSIDE, not a shell that wraps each toe. The earlier per-script approach
# (Laplacian smooth the toes, then push verts back out to the ORIGINAL skin
# surface) re-imprinted the individual toes — the skin-conforming clamp
# followed each toe bump, so bumps/pokes survived. This routine instead
# forces every toe cross-section onto one analytic half-ellipse dome that
# CIRCUMSCRIBES the toes (guaranteed outside the skin, so no poke, and no
# per-toe detail survives), extends the nose forward past the longest toe,
# and rebinds the whole box UNIFORMLY to the ball bone so it flexes rigidly
# at the ball joint with no per-vertex toe-weight ripple under animation.
#
# Applied PRE-offset: the mold encloses the skin toes by construction, then
# offset_outward adds the standoff uniformly over a smooth surface. No skin
# clamp is needed (or wanted) in the toe zone afterward.
# --------------------------------------------------------------------------
def _smoothstep(t):
t = min(max(t, 0.0), 1.0)
return t * t * (3.0 - 2.0 * t)
def foot_ball_u(armature):
"""Forward coord (u = y*FRONT_Y_SIGN) of the ball joint (toe-box hinge).
Bodies face -Y so toes point -Y; u increases toward the toes. ball_l/ball_r
share the same forward head coord (feet are x-mirror symmetric)."""
ball = armature.data.bones.get("ball_l")
if ball is None:
return None
return ball.head_local.y * FRONT_Y_SIGN
def _interp(x, xp, fp):
"""Minimal linear interp with flat ends (np.interp semantics, no import)."""
if x <= xp[0]:
return fp[0]
if x >= xp[-1]:
return fp[-1]
for i in range(1, len(xp)):
if x < xp[i]:
t = (x - xp[i - 1]) / max(xp[i] - xp[i - 1], 1e-9)
return fp[i - 1] + t * (fp[i] - fp[i - 1])
return fp[-1]
def convex_toe_box(shell, armature, *, extension, width_margin, height_clear,
nbins=10, feather_m=0.020, bottom_band_m=0.0015,
nose_frac=0.40, smooth_iters=7, smooth_factor=0.6,
uniform_ball_weights=True):
"""Reshape the forefoot into a smooth convex toe box (per foot side).
Each cross-section forward of the ball joint is forced onto ONE smooth
ellipse that circumscribes that slice's toe verts — every individual-toe
bump/crevice is erased and the shell sits OUTSIDE the skin (the ellipse is
the slice's own enclosing ellipse + a margin, so projecting only pushes
verts outward). Sizes are measured per u-slice (never a single collapsing
quadric, which over-inflates), so the box follows the foot's natural taper
while reading as one rigid cap. The frontmost `nose_frac` of the toe length
is pushed forward up to `extension` past the longest toe. The whole cap is
rebound uniformly to the ball bone so it flexes rigidly at the ball joint
with no per-vertex toe-weight ripple.
extension forward nose extension past the longest toe (m).
width_margin half-width padding added around each slice (m).
height_clear vertical headroom added above the toes (m) — flex room.
nbins number of u-slices sized independently along the toe length.
feather_m blend band behind the ball over which effect + rebind ramp.
bottom_band_m underside band left for the sole routine (dome does top+sides).
nose_frac fraction of the toe length (from the tip back) that is pushed
forward to form the extended rounded nose.
"""
ball_u = foot_ball_u(armature)
if ball_u is None:
log("WARNING: ball_l missing — convex toe box skipped")
return
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bm.normal_update() # pre-reshape normals gate the underside (sole) verts
dl = bm.verts.layers.deform.verify()
gi = {g.name: g.index for g in shell.vertex_groups}
ball_gi = {1: gi.get("ball_l"), -1: gi.get("ball_r")}
verts = list(bm.verts)
feather_u0 = ball_u - feather_m
total_reshaped = 0
for side in (1, -1):
sverts = [v for v in verts if (v.co.x * side) > 0.0]
toe = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > ball_u]
if len(toe) < 6:
continue
cx = sum(v.co.x for v in toe) / len(toe)
base_z = min(v.co.z for v in sverts) # per-side sole level
u_tip = max(v.co.y * FRONT_Y_SIGN for v in toe)
span = max(u_tip - ball_u, 1e-6)
# --- per-slice circumscribing ellipse (top + side verts only) --------
centers = [ball_u + span * (i + 0.5) / nbins for i in range(nbins)]
Barr = [width_margin] * nbins
Harr = [height_clear] * nbins
bin_verts = [[] for _ in range(nbins)]
for v in toe:
if (v.co.z - base_z) <= bottom_band_m:
continue # underside -> sole routine
i = int((v.co.y * FRONT_Y_SIGN - ball_u) / span * nbins)
i = min(max(i, 0), nbins - 1)
bin_verts[i].append(v)
for i in range(nbins):
bv = bin_verts[i]
if not bv:
continue
b0 = max(abs(v.co.x - cx) for v in bv) + width_margin
h0 = max(v.co.z - base_z for v in bv) + height_clear
# circumscribe: scale the (b0,h0) ellipse until it holds every vert
kmax = 1.0
for v in bv:
rr = (((v.co.x - cx) / b0) ** 2
+ ((v.co.z - base_z) / h0) ** 2) ** 0.5
kmax = max(kmax, rr)
Barr[i] = b0 * kmax
Harr[i] = h0 * kmax
# fill empty bins by carrying the last known size forward/back
for i in range(1, nbins):
if bin_verts[i] == [] or Barr[i] == width_margin:
Barr[i], Harr[i] = Barr[i - 1], Harr[i - 1]
# one along-length smoothing pass (keeps the cap from stepping)
Bs = list(Barr)
Hs = list(Harr)
for i in range(1, nbins - 1):
Bs[i] = 0.25 * Barr[i - 1] + 0.5 * Barr[i] + 0.25 * Barr[i + 1]
Hs[i] = 0.25 * Harr[i - 1] + 0.5 * Harr[i] + 0.25 * Harr[i + 1]
nose_start = u_tip - nose_frac * span
work = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > feather_u0]
# Capture the underside gate from the SKIN (pre-smooth) normals so it
# matches how the per-style sole routine classifies its verts (roughly
# normal.z < -0.5). Verts the sole owns are excluded from the cap, so
# the cap never fights the sole flatten (which caused underside tears).
gate = {}
for v in work:
gate[v.index] = _smoothstep((v.normal.z + 0.5) / 0.25) # -0.5->0
# --- fill the between-toe notches (Laplacian) BEFORE projecting -------
# The individual-toe crevices are deep valleys; in-place ellipse
# projection alone leaves their walls. Smoothing melts the valleys into
# one volume (like the old pipeline) — but the ellipse SIZES above were
# measured from the ORIGINAL toe, so the projection below pushes the
# smoothed (shrunk) surface back OUT onto a cap that still encloses the
# real skin. The uniform ball rebind fixes the flex ripple that made
# the old pipeline keep its smoothing timid.
if smooth_iters > 0:
toe_all = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > ball_u]
for _ in range(smooth_iters):
bmesh.ops.smooth_vert(bm, verts=toe_all, factor=smooth_factor,
use_axis_x=True, use_axis_y=True,
use_axis_z=True)
n_side = 0
for v in work:
u = v.co.y * FRONT_Y_SIGN
f = _smoothstep((u - feather_u0) / max(ball_u - feather_u0, 1e-6))
# ellipse size at this u (from the pre-stretch position)
B = _interp(u, centers, Bs)
H = _interp(u, centers, Hs)
hpos = v.co.z - base_z
wn = gate[v.index]
wh = 1.0 if hpos > bottom_band_m else 0.0
w = f * wn * wh
dx = v.co.x - cx
hh = max(hpos, 0.0)
rr = ((dx / B) ** 2 + (hh / H) ** 2) ** 0.5
if w > 1e-6 and rr > 1e-6:
scale = min(max(1.0 / rr, 0.5), 2.5)
tx = cx + dx * scale
tz = base_z + hh * scale
v.co.x += (tx - v.co.x) * w
v.co.z += (tz - v.co.z) * w
n_side += 1
# forward nose push (feathered from nose_start to the tip)
if u > nose_start:
t = _smoothstep((u - nose_start) / max(u_tip - nose_start, 1e-6))
v.co.y += -extension * t * FRONT_Y_SIGN * f
# Uniform ball rebinding (feathered by the length feather f).
bi = ball_gi[side]
if uniform_ball_weights and bi is not None:
for v in work:
u = v.co.y * FRONT_Y_SIGN
f = _smoothstep((u - feather_u0) / max(ball_u - feather_u0, 1e-6))
if f <= 1e-6:
continue
dv = v[dl]
for gidx in list(dv.keys()):
dv[gidx] = dv[gidx] * (1.0 - f)
cur = dv[bi] if bi in dv else 0.0
dv[bi] = cur + f
tot = sum(dv[g] for g in dv.keys())
if tot > 1e-8:
for gidx in list(dv.keys()):
dv[gidx] = dv[gidx] / tot
total_reshaped += n_side
log(f"toe box side {'L' if side > 0 else 'R'}: {len(toe)} toe verts, "
f"B={min(Bs) * 1000:.0f}-{max(Bs) * 1000:.0f}mm "
f"H={min(Hs) * 1000:.0f}-{max(Hs) * 1000:.0f}mm "
f"cap +{extension * 1000:.0f}mm, reshaped {n_side}")
bm.normal_update()
bm.to_mesh(me)
bm.free()
me.update()
log(f"convex toe box: reshaped {total_reshaped} verts "
f"(headroom {height_clear * 1000:.0f}mm, nose +{extension * 1000:.0f}mm)")
def solidify(shell, thickness):
"""Solidify with use_rim to give cloth thickness and cap the cut rims."""
bpy.ops.object.select_all(action='DESELECT')
shell.select_set(True)
bpy.context.view_layer.objects.active = shell
# consistent outward normals first
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')
sol = shell.modifiers.new(name="Solidify", type='SOLIDIFY')
sol.thickness = thickness
sol.offset = 1.0 # grow outward only
sol.use_rim = True # cap open boundaries (sleeve/neck/hem)
sol.use_rim_only = False
bpy.ops.object.modifier_apply(modifier=sol.name)
log(f"solidified: {thickness*1000:.0f} mm, use_rim; "
f"{len(shell.data.vertices)} verts")
# --------------------------------------------------------------------------
# Material (flat fabric albedo)
# --------------------------------------------------------------------------
def make_base_albedo_image(seed=1089):
img = bpy.data.images.new("garment_base_albedo", ALBEDO_SIZE, ALBEDO_SIZE, alpha=False)
rng = np.random.default_rng(seed)
base = np.array(FABRIC_RGB, dtype=np.float32)
noise = (rng.random((ALBEDO_SIZE * ALBEDO_SIZE, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE
rgb = np.clip(base[None, :] + noise, 0.0, 1.0)
rgba = np.concatenate([rgb, np.ones((ALBEDO_SIZE * ALBEDO_SIZE, 1), dtype=np.float32)], axis=1)
img.pixels.foreach_set(rgba.reshape(-1))
img.update()
return img
def assign_fabric_material(shell, albedo_img):
shell.data.materials.clear()
mat = bpy.data.materials.new("garment_fabric")
mat.use_nodes = True
nt = mat.node_tree
bsdf = nt.nodes.get("Principled BSDF")
tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = albedo_img
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
if "Roughness" in bsdf.inputs:
bsdf.inputs["Roughness"].default_value = 0.9
shell.data.materials.append(mat)
log("assigned flat fabric material (skin textures dropped)")
# --------------------------------------------------------------------------
# Region mask bake (UV0-aligned, per-face rasterization)
# --------------------------------------------------------------------------
def _classify_region(center, thr):
"""Return an RGBA region colour for a face centre (body-local coords)."""
x, z = center.x, center.z
if abs(x) >= thr["sleeve_x_abs"]:
return (0.0, 0.0, 1.0, 0.0) # sleeve caps -> B (tint[2])
if z >= thr["collar_z_min"] and abs(x) < thr["collar_x_abs"]:
return (1.0, 0.0, 0.0, 0.0) # neck collar band -> R (tint[0])
return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1])
def _tris_from_face(face, uv_layer):
"""Fan-triangulate a bmesh face into (uv, uv, uv) tuples in [0,1] space."""
loops = face.loops[:]
uvs = [loop[uv_layer].uv.copy() for loop in loops]
tris = []
for i in range(1, len(uvs) - 1):
tris.append((uvs[0], uvs[i], uvs[i + 1]))
return tris
def bake_region_mask(shell, out_path, thr):
"""Rasterize each face's UV0 triangle with its region colour into MASK_SIZE^2.
Background initialised to main-body green so bilinear bleed at island edges
never lands on an untinted (all-zero) texel.
"""
W = H = MASK_SIZE
buf = np.zeros((H, W, 4), dtype=np.float32)
buf[:, :, 1] = 1.0 # green background = main body
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for region mask bake")
region_counts = {"collar": 0, "body": 0, "sleeve": 0}
for face in bm.faces:
color = _classify_region(face.calc_center_median(), thr)
if color[0] > 0.5:
region_counts["collar"] += 1
elif color[2] > 0.5:
region_counts["sleeve"] += 1
else:
region_counts["body"] += 1
for a, b, c in _tris_from_face(face, uv_layer):
_raster_tri(buf, a, b, c, color, W, H)
bm.free()
total = max(sum(region_counts.values()), 1)
log("region faces: " + " ".join(
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in region_counts.items()))
# Blender image is bottom-up; buf row 0 is V=0 (bottom) already since we
# rasterise with row = v*(H-1). Save via Blender to match the texture pipe.
img = bpy.data.images.new("garment_region_mask", W, H, alpha=True)
img.pixels.foreach_set(buf.reshape(-1))
img.update()
img.filepath_raw = out_path
img.file_format = 'PNG'
img.save()
log(f"baked region mask -> {out_path}")
def _raster_tri(buf, a, b, c, color, W, H):
"""Barycentric fill of a UV triangle into buf (V=0 at row 0 = bottom)."""
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
region = buf[miny:maxy + 1, minx:maxx + 1, :]
col = np.array(color, dtype=np.float32)
region[inside] = col
# --------------------------------------------------------------------------
# Logo UV2 chest channel
# --------------------------------------------------------------------------
def author_logo_uv(shell, thr):
"""Create a 2nd UV layer projecting front chest faces into [0,1]; park the
rest outside the box (shader guards uv2 in [0,1])."""
me = shell.data
# Keep exactly two UV layers: primary (albedo/mask) + logo. Remove extras.
while len(me.uv_layers) > 1:
me.uv_layers.remove(me.uv_layers[-1])
logo_uv = me.uv_layers.new(name="logo_uv")
me.uv_layers.active = me.uv_layers[0] # keep albedo layer active for mask bake safety
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
bm.normal_update()
uvl = bm.loops.layers.uv.get("logo_uv")
x0, x1 = thr["chest_x"]
z0, z1 = thr["chest_z"]
placed = 0
for face in bm.faces:
center = face.calc_center_median()
on_chest = (
center.y * FRONT_Y_SIGN > CHEST_FRONT_Y
and x0 <= center.x <= x1
and z0 <= center.z <= z1
and face.normal.y * FRONT_Y_SIGN > CHEST_NORMAL_Y
)
for loop in face.loops:
if on_chest:
co = loop.vert.co
# Empirically calibrated for the -Y front so the wordmark reads
# upright and left-to-right from the camera (see report: a plain
# projection came out 180deg-rotated on this rig).
u = (co.x - x0) / (x1 - x0)
v = (co.z - z0) / (z1 - z0)
loop[uvl].uv = (min(max(u, 0.0), 1.0), min(max(v, 0.0), 1.0))
else:
loop[uvl].uv = (2.0, 2.0) # parked outside box
if on_chest:
placed += 1
bm.to_mesh(me)
bm.free()
me.update()
log(f"logo UV2 authored on {placed} chest faces")
if placed == 0:
log("WARNING: no chest faces matched — check CHEST_* box / front axis")
# --------------------------------------------------------------------------
# Export
# --------------------------------------------------------------------------
def export_reference(shell, armature, out_path):
bpy.ops.object.select_all(action='DESELECT')
shell.select_set(True)
armature.select_set(True)
bpy.context.view_layer.objects.active = armature
bpy.ops.export_scene.gltf(
filepath=out_path,
export_format='GLB',
use_selection=True,
export_apply=False, # keep Armature modifier for skinning
export_animations=False,
export_skins=True,
export_yup=True,
export_texcoords=True,
export_normals=True,
export_materials='EXPORT',
export_image_format='AUTO',
)
size_kb = os.path.getsize(out_path) // 1024
log(f"exported reference -> {out_path} ({size_kb} KB)")
def save_albedo_sidecar(albedo_img, out_path):
albedo_img.filepath_raw = out_path
albedo_img.file_format = 'PNG'
albedo_img.save()
log(f"saved base albedo -> {out_path}")
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def author_shell(body_dir, out_dir, glb_name, mask_name, offset):
"""Author one offset-shell garment from `body_dir`'s segments.
Shared by both modes; thresholds derive from the body's own armature so
the same proportional cut/mask parameters apply on every body.
"""
clear_scene()
shell, armature = build_covered_mesh(body_dir)
thr = derive_thresholds(armature)
sleeve_cut(shell, armature)
offset_outward(shell, offset)
solidify(shell, CLOTH_THICKNESS_M)
albedo_img = make_base_albedo_image()
assign_fabric_material(shell, albedo_img)
author_logo_uv(shell, thr) # do UV2 before mask bake (mask uses UV0/active)
bake_region_mask(shell, os.path.join(out_dir, mask_name), thr)
save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
export_reference(shell, armature, os.path.join(out_dir, glb_name))
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print("Usage: -- <bodies_dir>/average_m <out_dir> "
"[--offset M] [--sleeve-frac F]\n"
" or: -- <bodies_dir> <out_dir> --per-body "
"[--bodies a,b,c] [--offset M] [--sleeve-frac F]")
sys.exit(1)
in_dir = argv[0]
out_dir = argv[1]
per_body = "--per-body" in argv
global SLEEVE_FRAC
offset = None
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--sleeve-frac" in argv:
SLEEVE_FRAC = float(argv[argv.index("--sleeve-frac") + 1])
bodies = 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)
if not per_body:
# Single-reference mode: <in_dir> is one body's segment dir.
offset = OFFSET_M if offset is None else offset
body = os.path.basename(os.path.normpath(in_dir))
author_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png", offset)
log("DONE")
return
# Per-body mode: <in_dir> is the bodies root; loop each body's own segments.
offset = PER_BODY_OFFSET_M if offset is None else offset
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
results = []
for body in bodies:
body_dir = os.path.join(in_dir, body)
log(f"=== {body} ===")
if not os.path.isdir(body_dir):
results.append((body, "skipped: body dir missing"))
continue
try:
author_shell(body_dir, out_dir, f"{body}.glb", f"{body}_mask.png",
offset)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
results.append((body, f"error: {exc}"))
# Runtime fallback + SD-fit reference compatibility: reference_mask.png
# mirrors the reference body's mask.
ref_mask = os.path.join(out_dir, f"{REFERENCE_BODY}_mask.png")
if os.path.isfile(ref_mask):
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
log(f"copied {REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
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,373 @@
"""
blender_author_offset_shell_legs.py (T-1089, lower-body offset-shell garments)
Companion to blender_author_offset_shell.py (imported as a module — scene
helpers, offset/solidify, albedo, rasterizer and export are reused, not
copied). Where the base script is calibrated for TORSO garments (sleeve cut,
collar/sleeve regions, chest logo UV), this one authors LOWER-BODY garments
from seg_hips + leg segments with parameterized cuts/regions, so one script
serves shorts, jeans, joggers, formal pants, swim trunks:
--coverage thigh|full thigh: seg_hips + seg_leg_upper_l/r (shorts)
full: + seg_leg_lower_l/r (jeans/joggers/formal)
--hem-frac F fraction of the hem bone's length KEPT below its
head (bone-plane cut). Hem bone = thigh for
--coverage thigh, calf for full. 0.78 on the thigh
= casual shorts hem ~9 cm above the knee.
--waistband-frac F waistband band height as a fraction of
(waist rim z thigh head z). The band is
classified R in the region mask; everything else
is G. (B/A unused — 2-region garment.)
--fabric R,G,B flat toon-friendly base tone (luma ~0.6 keeps the
toon_garment.gdshader luma-recolor faithful).
--seed N albedo noise seed (deterministic output).
Cut/region thresholds derive PER BODY from that body's own landmarks, same
philosophy as the base script: the hem plane comes from the thigh/calf bone
(head→tail, identical 65-bone rig on all 11 bodies), the waist rim is the
natural top boundary of seg_hips (its own mesh z-max), so proportions match
across bodies. The waistline itself comes free as a segment boundary — no
waist join geometry needed.
Regions (RGBA mask, toon_garment.gdshader): waistband → R (tint_0),
body → G (tint_1). A parked logo_uv TEXCOORD_1 layer is authored for channel
consistency with torso garments (all UVs outside [0,1] → shader draws nothing).
Modes mirror the base script:
PER-BODY (preferred for offset shells, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
<bodies_dir> <out_dir> --per-body \
[--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35]
Writes <out_dir>/<body>.glb + <out_dir>/<body>_mask.png per body, plus
shared base_albedo.png and reference_mask.png (= average_m's mask, runtime
fallback for the compositor).
SINGLE-REFERENCE:
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
<bodies_dir>/average_m <out_dir> [--offset 0.020] [...]
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
"""
import sys
import os
import shutil
import importlib.util
import bpy # noqa: F401 (Blender runtime)
import bmesh
import numpy as np
# --------------------------------------------------------------------------
# Import the base authoring module (shared helpers; main() is __main__-guarded)
# --------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_file_location(
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
base = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(base)
log = base.log
# --------------------------------------------------------------------------
# Parameters (defaults = shorts_modern)
# --------------------------------------------------------------------------
COVERAGE_SEGMENTS = {
"thigh": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"],
"full": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r"],
}
HEM_BONES = {"thigh": ("thigh_l", "thigh_r"), "full": ("calf_l", "calf_r")}
HEM_FRAC = 0.78 # keep 78% of the thigh → hem above the knee
WAISTBAND_FRAC = 0.35 # top 35% of waist-rim→thigh-head span = waistband (R)
FABRIC_RGB = (0.63, 0.60, 0.53) # warm stone khaki, luma ~0.60 (toon-friendly)
ALBEDO_SEED = 20890 # deterministic albedo noise, distinct from the tshirt
# --------------------------------------------------------------------------
# Per-body threshold derivation
# --------------------------------------------------------------------------
def derive_leg_thresholds(armature, coverage, hem_frac):
"""Hem plane from this body's own hem bone; thigh head z for the waistband
span. All 11 bodies share the 65-bone rig, so the landmarks always exist."""
bones = armature.data.bones
hem_l, hem_r = HEM_BONES[coverage]
zs = []
thigh_heads = []
for name in (hem_l, hem_r):
b = bones.get(name)
if b is None:
raise RuntimeError(f"hem bone {name} missing on armature")
zs.append(b.head_local.z + hem_frac * (b.tail_local.z - b.head_local.z))
for name in ("thigh_l", "thigh_r"):
b = bones.get(name)
if b is None:
raise RuntimeError(f"landmark bone {name} missing on armature")
thigh_heads.append(b.head_local.z)
thr = {
"hem_z": sum(zs) / len(zs),
"thigh_head_z": sum(thigh_heads) / len(thigh_heads),
}
log(f"thresholds: hem z>={thr['hem_z']:.3f} "
f"(hem bone {hem_l}/{hem_r}, keep {hem_frac:.2f}) "
f"thigh head z={thr['thigh_head_z']:.3f}")
return thr
# --------------------------------------------------------------------------
# Seam weld — the body segment meshes are assembled from patches whose seam
# vertices are coincident but DUPLICATED. Each copy's normal averages only its
# own patch's faces, so the outward offset pulls seams apart (visible slit at
# the front-centre of seg_hips, V-notches on the waist rim). Welding before
# the offset merges the copies (weights/UVs are identical on coincident verts)
# and gives one smooth normal per seam vertex.
# --------------------------------------------------------------------------
def weld_seams(shell, epsilon=1e-4):
bm = bmesh.new()
bm.from_mesh(shell.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=epsilon)
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
after = len(shell.data.vertices)
log(f"seam weld merged {before - after} duplicate verts; {after} remain")
# --------------------------------------------------------------------------
# Bone-plane hem cut (z threshold — legs hang along -Z in rest pose)
# --------------------------------------------------------------------------
def hem_cut(shell, hem_z):
"""Delete every vert below the hem plane. A single global z threshold is
safe: the hips segment bottoms out far above any sensible hem."""
bm = bmesh.new()
bm.from_mesh(shell.data)
bm.verts.ensure_lookup_table()
to_delete = [v for v in bm.verts if v.co.z < hem_z]
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
bm.to_mesh(shell.data)
bm.free()
shell.data.update()
log(f"hem cut removed {len(to_delete)} verts below z={hem_z:.3f}; "
f"{len(shell.data.vertices)} remain")
# --------------------------------------------------------------------------
# Region mask: waistband (R) / body (G)
# --------------------------------------------------------------------------
def bake_region_mask_legs(shell, out_path, thr, waistband_frac):
"""Rasterize UV0 faces: waistband → R, everything else → G.
MUST run PRE-solidify (open boundaries still present). The waistband is
boundary-anchored: the waist rim is the top open boundary loop of seg_hips
(the rim DIPS ~5 cm at the navel, so a global z-max test misses the front
row), so a face is waistband when it touches a rim-boundary vertex — that
keys the full top face row on every tessellation — or when its centre lies
inside the proportional band. Solidify afterwards duplicates the UV loops
unchanged, so the baked texels serve outer shell, inner shell and rim caps
alike."""
W = H = base.MASK_SIZE
buf = np.zeros((H, W, 4), dtype=np.float32)
buf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
bm.verts.ensure_lookup_table()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for region mask bake")
waist_rim_z = max(v.co.z for v in bm.verts)
band = waistband_frac * (waist_rim_z - thr["thigh_head_z"])
wb_z_min = waist_rim_z - band
# Waist-rim boundary verts: on an open boundary AND above the thigh head
# (the only other boundaries are the leg hems, far below).
rim_verts = set()
for e in bm.edges:
if e.is_boundary:
for v in e.verts:
if v.co.z >= thr["thigh_head_z"]:
rim_verts.add(v.index)
log(f"waistband: rim z={waist_rim_z:.3f} band {band*100:.1f} cm "
f"→ R for z>={wb_z_min:.3f} or touching {len(rim_verts)} rim verts")
counts = {"waistband": 0, "body": 0}
for face in bm.faces:
in_band = face.calc_center_median().z >= wb_z_min
touches_rim = any(v.index in rim_verts for v in face.verts)
if in_band or touches_rim:
color = (1.0, 0.0, 0.0, 0.0)
counts["waistband"] += 1
else:
color = (0.0, 1.0, 0.0, 0.0)
counts["body"] += 1
for a, b, c in base._tris_from_face(face, uv_layer):
base._raster_tri(buf, a, b, c, color, 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()))
img = bpy.data.images.new("garment_region_mask", W, H, alpha=True)
img.pixels.foreach_set(buf.reshape(-1))
img.update()
img.filepath_raw = out_path
img.file_format = 'PNG'
img.save()
log(f"baked region mask -> {out_path}")
def park_logo_uv(shell):
"""Author a TEXCOORD_1 layer with every loop parked outside [0,1] — keeps
the UV-channel layout identical to torso garments; the shader's in-box
guard means nothing ever draws."""
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 parked (no logo region on this garment)")
# --------------------------------------------------------------------------
# Author one body
# --------------------------------------------------------------------------
def author_legs_shell(body_dir, out_dir, glb_name, mask_name, offset,
coverage, hem_frac, waistband_frac, seed):
base.clear_scene()
base.COVERED_SEGMENTS = COVERAGE_SEGMENTS[coverage]
shell, armature = base.build_covered_mesh(body_dir)
weld_seams(shell)
thr = derive_leg_thresholds(armature, coverage, hem_frac)
hem_cut(shell, thr["hem_z"])
base.offset_outward(shell, offset)
# Region mask + parked logo UV are authored PRE-solidify: the boundary-
# anchored waistband needs the open waist rim, and solidify duplicates all
# UV loops unchanged so the baked texels stay valid for the final mesh.
park_logo_uv(shell)
bake_region_mask_legs(shell, os.path.join(out_dir, mask_name), thr,
waistband_frac)
base.solidify(shell, base.CLOTH_THICKNESS_M)
base.FABRIC_RGB = FABRIC_RGB
albedo_img = base.make_base_albedo_image(seed=seed)
base.assign_fabric_material(shell, albedo_img)
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
base.export_reference(shell, armature, os.path.join(out_dir, glb_name))
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 2:
print("Usage: -- <bodies_dir>/average_m <out_dir> [--offset M] "
"[--coverage thigh|full] [--hem-frac F] [--waistband-frac F] "
"[--fabric R,G,B] [--seed N]\n"
" or: -- <bodies_dir> <out_dir> --per-body [--bodies a,b,c] "
"[same flags]")
sys.exit(1)
in_dir = argv[0]
out_dir = argv[1]
per_body = "--per-body" in argv
global FABRIC_RGB
offset = None
coverage = "thigh"
hem_frac = HEM_FRAC
waistband_frac = WAISTBAND_FRAC
seed = ALBEDO_SEED
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--coverage" in argv:
coverage = argv[argv.index("--coverage") + 1]
if coverage not in COVERAGE_SEGMENTS:
print(f"unknown --coverage {coverage}")
sys.exit(1)
if "--hem-frac" in argv:
hem_frac = float(argv[argv.index("--hem-frac") + 1])
if "--waistband-frac" in argv:
waistband_frac = float(argv[argv.index("--waistband-frac") + 1])
if "--fabric" in argv:
FABRIC_RGB = tuple(
float(c) for c in argv[argv.index("--fabric") + 1].split(","))
if "--seed" in argv:
seed = int(argv[argv.index("--seed") + 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)
if not per_body:
offset = base.OFFSET_M if offset is None else offset
body = os.path.basename(os.path.normpath(in_dir))
author_legs_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png",
offset, coverage, hem_frac, waistband_frac, seed)
log("DONE")
return
offset = base.PER_BODY_OFFSET_M if offset is None else offset
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
f"coverage={coverage} hem_frac={hem_frac} waistband_frac={waistband_frac}")
results = []
for body in bodies:
body_dir = os.path.join(in_dir, body)
log(f"=== {body} ===")
if not os.path.isdir(body_dir):
results.append((body, "skipped: body dir missing"))
continue
try:
author_legs_shell(body_dir, out_dir, f"{body}.glb",
f"{body}_mask.png", offset, coverage, hem_frac,
waistband_frac, seed)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
results.append((body, f"error: {exc}"))
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(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
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,445 @@
"""
blender_author_outerwear.py (T-1089, jacket_modern — open-front outerwear family)
Companion to blender_author_offset_shell.py: authors OUTERWEAR offset-shells
(jacket / track-jacket / parka family) per body. It imports the base module and
reuses its scene/join/offset/solidify/mask/logo/export machinery; everything
garment-specific lives in the PARAMS block below, so a new outerwear piece is a
parameter delta, not a new script.
What it adds over the base t-shirt shell:
* FULL SLEEVES — covers seg_arm_lower_l/r too; the sleeve cut moves from the
upper arm to a WRIST cut on the lowerarm bones (same bone-plane technique).
* 4th REGION (A = zip/trim) — a front zip placket strip running hem -> through
the collar, plus knit cuff bands on the sleeve ends. Region layout per spec:
collar=R, body=G, sleeves=B, zip/trim=A.
* POSITION-PAINTED ALBEDO — instead of the base script's flat-noise albedo,
each body gets an albedo baked in its own UV0 layout by rasterizing every
face and painting per-texel from interpolated body-local position: zip teeth
dashes + placket, panel seam lines (shoulder, collar, cuff, placket edges),
hem band. The toon_garment shader recolors by LUMA, so the painted detail is
authored as luma contrast and survives any tint choice.
* LEFT-BREAST LOGO PATCH — the UV2 chest box shifts off-centre (the zip owns
the centre line), scaled per body from the same bone-landmark ratios.
Per-body only (this family is the poster child for the Q-060 per-body route):
tooling/blender --background --python \
tooling/garment-fit/blender_author_outerwear.py -- \
<bodies_root> <out_dir> \
[--bodies a,b,c] [--offset 0.022] [--cuff-keep 0.88]
Writes per body: <out_dir>/<body>.glb (painted albedo embedded), <body>_mask.png
Plus: <out_dir>/base_albedo.png (average_m's painted albedo)
<out_dir>/reference_mask.png (copy of average_m's, fallback)
The per-body albedo is embedded in the GLB under the image name "base_albedo"
(tshirt convention): Godot's importer extracts it as <body>_base_albedo.png.
average_m is deliberately authored LAST so the shared base_albedo.png sidecar
ends up holding the reference body's pixels.
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
"""
import importlib.util
import os
import shutil
import sys
import bpy
import bmesh
import numpy as np
# --------------------------------------------------------------------------
# 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)
log = base.log
# --------------------------------------------------------------------------
# PARAMS — jacket_modern (casual zip jacket). A new outerwear garment should
# only need to touch this block (or override via CLI where exposed).
# --------------------------------------------------------------------------
# Full-arm coverage: torso + torso_upper + both whole arms.
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.022 # LARGEST standoff of the tops — worn over a shirt
# (tshirt_modern outer surface ~16 mm), must neither
# clip the body nor read skin-tight.
CLOTH_THICKNESS_M = 0.005 # beefier cloth than the 4 mm tee
CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept (wrist cut — hands show)
CUFF_BAND_FRAC = 0.22 # last fraction of the KEPT forearm = knit cuff trim (A)
# Region-shape ratios, anchored to the base module's average_m reference
# landmarks (shoulder |x| 0.1919, neck len 0.0793) so they scale per body.
ZIP_HALF_FRAC = 0.030 / 0.1919 # zip placket half-width as frac of shoulder |x|
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below neck head
# (t-shirt band was 0.3846 — jacket collar is taller,
# but 0.80 caught upper-chest faces and rendered as
# jagged shoulder "wings"; 0.55 keeps a neat band)
COLLAR_X_MULT = 1.15 # jacket collar slightly wider than the tee band
# Left-breast logo patch (character-left = +X; centre line belongs to the zip).
# Fracs of shoulder |x| / spine span, from average_m absolutes (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-albedo luma palette (the shader tints by luma: tint * (luma*1.5+0.2)).
BASE_LUMA = 0.58
PLACKET_LUMA = 0.50 # slightly darker front placket band
SEAM_LUMA = 0.38 # panel seam / stitch lines
TEETH_LUMA = 0.88 # bright zip teeth dashes
TEETH_GAP_LUMA = 0.34 # dark tape between dashes
CUFF_LUMA = 0.48 # knit cuff band
HEM_LUMA = 0.50 # bottom hem band
ALBEDO_NOISE = 0.02 # woven jitter
SEAM_W = 0.005 # seam line half-width, metres
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_M = 0.020 # hem band height, metres
# Slate hue applied on top of luma (only visible if the region mask is missing).
FABRIC_HUE = np.array([0.93, 0.97, 1.06], dtype=np.float32)
ALBEDO_SIZE = 512
# --------------------------------------------------------------------------
# Thresholds — base derivation + outerwear extras from the same armature
# --------------------------------------------------------------------------
def outerwear_thresholds(armature):
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")
spine01 = bones.get("spine_01")
# Shoulder |x| (same landmark the base derivation uses).
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 = 0.1919 # average_m fallback
# Taller, wider standing 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["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 + cuff trim band 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 + CUFF_KEEP_FRAC * (tail_x - head_x)
band_x = head_x + (CUFF_KEEP_FRAC - 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
# 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"outerwear thresholds: zip half {thr['zip_half_x']:.3f} "
f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_x_abs']:.3f} "
f"cuff |x|>={thr['cuff_x_abs']:.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
# --------------------------------------------------------------------------
# Wrist cut (bone-plane, same technique as the base sleeve cut)
# --------------------------------------------------------------------------
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")
# --------------------------------------------------------------------------
# 4-region classifier (collar=R, body=G, sleeves=B, zip/trim=A)
# --------------------------------------------------------------------------
def classify_region_outerwear(center, thr):
x, y, z = center.x, center.y, center.z
ax = abs(x)
front = y * base.FRONT_Y_SIGN > 0.0
# Zip placket first: runs hem -> THROUGH the collar (zip-through jacket).
if front and ax < thr["zip_half_x"]:
return (0.0, 0.0, 0.0, 1.0) # zip/trim -> A (tint[3])
if ax >= thr["cuff_x_abs"]:
return (0.0, 0.0, 0.0, 1.0) # knit cuff trim -> A (tint[3])
if ax >= thr["sleeve_x_abs"]:
return (0.0, 0.0, 1.0, 0.0) # sleeves -> B (tint[2])
if z >= thr["collar_z_min"] and ax < thr["collar_x_abs"]:
return (1.0, 0.0, 0.0, 0.0) # collar -> R (tint[0])
return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1])
# --------------------------------------------------------------------------
# Position-painted albedo (per-body UV0 bake)
# --------------------------------------------------------------------------
def _paint_luma(x, y, z, thr, z_min):
"""Vectorised luma paint from body-local position (arrays in, array out)."""
v = np.full(x.shape, BASE_LUMA, dtype=np.float32)
ax = np.abs(x)
front = (y * base.FRONT_Y_SIGN) > 0.0
# Hem band along the jacket bottom.
v = np.where(z < z_min + HEM_BAND_M, HEM_LUMA, v)
# Knit cuff bands.
v = np.where(ax >= thr["cuff_x_abs"], CUFF_LUMA, v)
# Panel seam lines: shoulder (sleeve boundary), cuff start, collar base.
v = np.where(np.abs(ax - thr["sleeve_x_abs"]) < SEAM_W, SEAM_LUMA, v)
v = np.where(np.abs(ax - thr["cuff_x_abs"]) < SEAM_W, SEAM_LUMA, v)
collar_seam = (np.abs(z - thr["collar_z_min"]) < SEAM_W) \
& (ax < thr["collar_x_abs"] * 1.2)
v = np.where(collar_seam, SEAM_LUMA, v)
# Front zip placket: darker band + stitch edges + dashed teeth line.
zh = thr["zip_half_x"]
v = np.where(front & (ax < zh), PLACKET_LUMA, v)
v = np.where(front & (np.abs(ax - zh) < SEAM_W * 0.6), SEAM_LUMA, v)
teeth = front & (ax < TEETH_HALF_W)
dash = np.mod(z, TEETH_PERIOD) < TEETH_DUTY
v = np.where(teeth & dash, TEETH_LUMA, v)
v = np.where(teeth & ~dash, TEETH_GAP_LUMA, v)
return v
def _raster_tri_painted(lum, uv_a, uv_b, uv_c, p_a, p_b, p_c, thr, z_min, W, H):
"""Barycentric fill of a UV triangle, painting per-texel from the
barycentrically interpolated body-local position."""
ax_, ay_ = uv_a.x * (W - 1), uv_a.y * (H - 1)
bx_, by_ = uv_b.x * (W - 1), uv_b.y * (H - 1)
cx_, cy_ = uv_c.x * (W - 1), uv_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
pos_x = w0 * p_a.x + w1 * p_b.x + w2 * p_c.x
pos_y = w0 * p_a.y + w1 * p_b.y + w2 * p_c.y
pos_z = w0 * p_a.z + w1 * p_b.z + w2 * p_c.z
vals = _paint_luma(pos_x, pos_y, pos_z, thr, z_min)
region = lum[miny:maxy + 1, minx:maxx + 1]
region[inside] = vals[inside]
def bake_painted_albedo(shell, thr, out_path, seed=1093):
"""Bake a per-body albedo: luma-painted detail in this body's UV0 layout."""
W = H = ALBEDO_SIZE
lum = np.full((H, W), BASE_LUMA, dtype=np.float32)
me = shell.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
raise RuntimeError("no active UV layer for albedo bake")
z_min = min(v.co.z for v in bm.verts)
for face in bm.faces:
loops = face.loops[:]
uvs = [loop[uv_layer].uv.copy() for loop in loops]
pos = [loop.vert.co.copy() for loop in loops]
for i in range(1, len(uvs) - 1):
_raster_tri_painted(lum, uvs[0], uvs[i], uvs[i + 1],
pos[0], pos[i], pos[i + 1], thr, z_min, W, H)
bm.free()
rng = np.random.default_rng(seed)
lum += (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
rgb = np.clip(lum[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0)
rgba = np.concatenate(
[rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2).astype(np.float32)
img = bpy.data.images.new("garment_painted_albedo", W, H, alpha=False)
img.pixels.foreach_set(rgba.reshape(-1))
img.update()
img.filepath_raw = out_path
img.file_format = 'PNG'
img.save()
log(f"baked painted albedo -> {out_path}")
return img
# --------------------------------------------------------------------------
# Per-body authoring
# --------------------------------------------------------------------------
def author_outerwear(body_dir, out_dir, body, offset):
base.clear_scene()
shell, armature = base.build_covered_mesh(body_dir)
thr = outerwear_thresholds(armature)
wrist_cut(shell, thr)
base.offset_outward(shell, offset)
base.solidify(shell, CLOTH_THICKNESS_M)
# Shared sidecar name on purpose: the exporter derives the embedded glTF
# image name from the filepath basename, and Godot extracts embedded
# textures as <glb>_<image>.png — so this yields <body>_base_albedo.png
# on import, matching the tshirt_modern convention (no doubled names).
albedo_path = os.path.join(out_dir, "base_albedo.png")
albedo_img = bake_painted_albedo(shell, thr, albedo_path)
base.assign_fabric_material(shell, albedo_img)
base.author_logo_uv(shell, thr) # UV2 before mask bake (mask uses UV0/active)
base.bake_region_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr)
# Honest region tally (the base bake log can't see the A channel).
counts = {"collar": 0, "body": 0, "sleeve": 0, "zip/trim": 0}
bm = bmesh.new()
bm.from_mesh(shell.data)
for face in bm.faces:
c = classify_region_outerwear(face.calc_center_median(), thr)
if c[3] > 0.5:
counts["zip/trim"] += 1
elif c[2] > 0.5:
counts["sleeve"] += 1
elif c[0] > 0.5:
counts["collar"] += 1
else:
counts["body"] += 1
bm.free()
total = max(sum(counts.values()), 1)
log("outerwear regions: " + " ".join(
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
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] [--cuff-keep F]")
sys.exit(1)
bodies_root = argv[0]
out_dir = argv[1]
global CUFF_KEEP_FRAC
offset = OFFSET_M
if "--offset" in argv:
offset = float(argv[argv.index("--offset") + 1])
if "--cuff-keep" in argv:
CUFF_KEEP_FRAC = float(argv[argv.index("--cuff-keep") + 1])
bodies = base.BODY_TYPES
if "--bodies" in argv:
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
# Author the reference body LAST so the shared base_albedo.png sidecar
# (rewritten per body before each export) ends as average_m's version.
if base.REFERENCE_BODY in bodies:
bodies = [b for b in bodies if b != base.REFERENCE_BODY] + [base.REFERENCE_BODY]
os.makedirs(out_dir, exist_ok=True)
# Route the base mask bake through the 4-region outerwear classifier and
# the covered-segment import through the full-arm list.
base._classify_region = classify_region_outerwear
base.COVERED_SEGMENTS = SEGMENTS
log(f"outerwear per-body mode: {len(bodies)} bodies, "
f"offset {offset*1000:.0f} mm, cuff keep {CUFF_KEEP_FRAC:.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_outerwear(body_dir, out_dir, body, offset)
results.append((body, "ok"))
except Exception as exc:
log(f"ERROR {body}: {exc}")
results.append((body, f"error: {exc}"))
# Runtime fallback: reference_mask.png mirrors average_m's mask.
# (base_albedo.png already holds average_m's albedo — it authored last.)
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(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png")
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,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,718 @@
"""
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
RIM_RELAX_PASSES = 2 # XY neighbour-average passes on the topline ring
# Convex toe box (shared base.convex_toe_box) — sleek, low, tapered but SMOOTH.
TOE_EXT_M = 0.008 # nose extension past the longest toe (m)
TOE_WMARGIN_M = 0.0025 # half-width padding (snug formal last)
TOE_HCLEAR_M = 0.004 # vertical headroom above the toes (low profile)
TOE_FEATHER_M = 0.018 # blend band behind the ball
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 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, skip_forward_of_u=None):
"""Push any shell vert closer than `min_clearance` to the skin snapshot
out to exactly that standoff (along the skin normal). Protects the instep /
throat, where the rim relax can migrate verts toward the skin.
The toe zone is EXCLUDED (skip_forward_of_u): base.convex_toe_box builds an
analytic cap that already stands off the skin by construction; re-snapping
it to the skin here would re-imprint the individual toes (the original
bug). Verts with forward coord u = y*FRONT_Y_SIGN > skip_forward_of_u are
left untouched."""
me = shell.data
pushed = 0
worst = 0.0
for v in me.vertices:
if skip_forward_of_u is not None and \
(v.co.y * FRONT_Y_SIGN) > skip_forward_of_u:
continue
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)
sole_idx = classify_sole_verts(shell) # skin downward normals
skin_bvh = snapshot_skin_bvh(shell) # instep clearance reference
# Smooth convex toe box (replaces Laplacian smooth + skin-conforming clamp,
# which re-imprinted the individual toes). Runs on the raw skin toe so the
# cap encloses the real toes; offset then adds standoff.
ball_u = lm.ball_head_y * FRONT_Y_SIGN
base.convex_toe_box(
shell, armature,
extension=TOE_EXT_M * lm.s, width_margin=TOE_WMARGIN_M * lm.s,
height_clear=TOE_HCLEAR_M * lm.s, feather_m=TOE_FEATHER_M * lm.s)
base.offset_outward(shell, offset)
# Instep/throat clearance only — the toe zone is excluded so the analytic
# cap is never re-snapped to the skin toes.
enforce_clearance(shell, skin_bvh, offset, skip_forward_of_u=ball_u)
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,753 @@
"""
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)
SMOOTH_GLOBAL_ITERS = 2 # light instep/ankle de-lumping passes (behind ball;
# the toe box is built analytically, not smoothed)
SMOOTH_FACTOR = 0.5
# Convex toe box (shared base.convex_toe_box) — rounded, roomy trainer cap.
TOE_EXT_M = 0.012 # nose extension past the longest toe (m)
TOE_WMARGIN_M = 0.006 # half-width padding around the widest toe (roomy)
TOE_HCLEAR_M = 0.010 # vertical headroom above the toes (flex room)
TOE_FEATHER_M = 0.022 # blend band behind the ball
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):
"""De-lump the instep/ankle anatomy only (BEHIND the ball joint): light
iterative vertex smoothing with the open collar boundary pinned. The toe
box itself is built analytically by base.convex_toe_box, so the toe zone
(forward of the ball) is deliberately excluded here — smoothing it would
shrink the toes the toe box must still enclose."""
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)
ball_u = lm.ball_y * FRONT_Y_SIGN
# interior verts BEHIND the ball joint (u < ball_u): instep, arch, heel.
heel = [v for v in bm.verts if v.index not in boundary
and (v.co.y * FRONT_Y_SIGN) < ball_u]
for _ in range(SMOOTH_GLOBAL_ITERS):
bmesh.ops.smooth_vert(bm, verts=heel, 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 instep/heel: {SMOOTH_GLOBAL_ITERS} passes "
f"({len(heel)} verts behind ball u<{ball_u:.4f})")
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)
# Convex toe box FIRST, on the raw skin foot (so it encloses the real
# toes), then de-lump only behind the ball.
base.convex_toe_box(
shell, armature,
extension=TOE_EXT_M * lm.s, width_margin=TOE_WMARGIN_M * lm.s,
height_clear=TOE_HCLEAR_M * lm.s, feather_m=TOE_FEATHER_M * lm.s)
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
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 N_LACES, PLAIN, SOLE_SNAP_FRAC
global TOE_EXT_M, TOE_WMARGIN_M, TOE_HCLEAR_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] [--sole-drop M] [--sole-snap-frac F] "
"[--collar-frac F] [--collar-flare M] [--toe-ext M] "
"[--toe-wmargin M] [--toe-hclear M] "
"[--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 "--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-ext" in argv:
TOE_EXT_M = float(argv[argv.index("--toe-ext") + 1])
if "--toe-wmargin" in argv:
TOE_WMARGIN_M = float(argv[argv.index("--toe-wmargin") + 1])
if "--toe-hclear" in argv:
TOE_HCLEAR_M = float(argv[argv.index("--toe-hclear") + 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,445 @@
"""
blender_batch_fit_skinned.py (T-1089 gap G1 the biggest wardrobe gap)
Fit a reference clothing GLB (authored on average_m) to every body type and
export ANIMATABLE skinned variants. This supersedes
tooling/blender_surface_deform_batch.py, whose output used export_skins=False
(:142) those variants cannot animate on the shared skeleton, which is how the
runtime loads clothing (character_visual.gd:582-594).
It merges three proven codepaths that had never been combined:
* the 11-body loop + headless temp_override Surface-Deform bind + Shrinkwrap
fallback + pipeline_log.json from blender_surface_deform_batch.py
* the weight flow from
spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py:160-257
Surface Deform bind -> apply -> Data Transfer VGROUP_WEIGHTS
(POLYINTERP_NEAREST) from the fitted body -> normalize -> retarget the
armature modifier to the body's armature -> export_skins=True
* optional Solidify from tooling/convert_outfit.py (skipped by
default; offset-shell references are already solidified)
Per body type:
average_m (REFERENCE_BODY): direct copy of the reference GLB (already correct).
others: SD-bind the reference garment to the target body surface, bake the
deformed rest shape, transfer + normalise weights from that body, retarget the
armature, export a skinned GLB.
Run:
tooling/blender --background --python \
tooling/garment-fit/blender_batch_fit_skinned.py -- \
<reference_glb> <bodies_dir> <output_dir> \
[--only average_m,average_f,...] [--solidify 0.0] [--self-check]
Output:
<output_dir>/<body_type>.glb one skinned variant per fitted body type
<output_dir>/pipeline_log.json per-variant method (surface_deform/shrinkwrap/
copy) + status, for the review gate
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
"""
import sys
import os
import json
import glob
import shutil
import bpy
BODY_TYPES = [
"thin_m", "thin_f", "average_m", "average_f", "muscular_m", "muscular_f",
"teen_m", "teen_f", "heavy_m", "heavy_f", "child",
]
REFERENCE_BODY = "average_m"
SHRINKWRAP_OFFSET = 0.002
SD_FALLOFF = 4.0 # generous — clothing sits proud of the body (fit_outfits :212)
def log(msg):
print(f"[batch-fit] {msg}")
# --------------------------------------------------------------------------
# Scene helpers
# --------------------------------------------------------------------------
def clear_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
bpy.ops.outliner.orphans_purge(do_recursive=True)
def import_glb(path):
before = set(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=path)
return [o for o in bpy.context.scene.objects if o not in before]
def is_body_mesh(obj):
"""Skinned body-segment mesh — excludes Icosphere debris that rides in GLBs."""
return (
obj.type == 'MESH'
and not obj.name.startswith("Icosphere")
and len(obj.vertex_groups) > 0
and len(obj.data.vertices) >= 50
)
def deselect_all():
bpy.ops.object.select_all(action='DESELECT')
def set_active(obj):
bpy.context.view_layer.objects.active = obj
# --------------------------------------------------------------------------
# Body surface (join of skinned segments) + one target armature
# --------------------------------------------------------------------------
def build_body(bodies_dir, body_type):
"""Import all seg_*.glb for a body; return (body_surface_mesh, armature).
The surface is a join of the skinned segment meshes (keeps merged vertex
groups so it can serve as both the Surface-Deform target and the weight
source). One armature is kept as the retarget destination; extras dropped.
"""
body_dir = os.path.join(bodies_dir, body_type)
if not os.path.isdir(body_dir):
return None, None
seg_paths = sorted(glob.glob(os.path.join(body_dir, "seg_*.glb")))
meshes = []
armature = None
for p in seg_paths:
for o in import_glb(p):
if o.type == 'ARMATURE' and armature is None:
armature = o
elif o.type == 'ARMATURE':
bpy.data.objects.remove(o, do_unlink=True)
elif is_body_mesh(o):
meshes.append(o)
elif o.type == 'MESH':
bpy.data.objects.remove(o, do_unlink=True)
if not meshes or armature is None:
return None, None
deselect_all()
for m in meshes:
m.select_set(True)
set_active(meshes[0])
bpy.ops.object.join()
surface = bpy.context.active_object
surface.name = f"body_surface_{body_type}"
# Detach from armature so it is pure geometry for SD/weight source.
for mod in list(surface.modifiers):
if mod.type == 'ARMATURE':
surface.modifiers.remove(mod)
return surface, armature
# --------------------------------------------------------------------------
# Reference garment
# --------------------------------------------------------------------------
def import_reference_garment(reference_glb):
"""Import the reference garment; return its single skinned mesh + armature."""
objs = import_glb(reference_glb)
meshes = [o for o in objs if is_body_mesh(o)]
if not meshes:
# offset-shell garments always have vgroups; guard anyway
meshes = [o for o in objs if o.type == 'MESH' and len(o.data.vertices) >= 20]
armature = next((o for o in objs if o.type == 'ARMATURE'), None)
if len(meshes) > 1:
deselect_all()
for m in meshes:
m.select_set(True)
set_active(meshes[0])
bpy.ops.object.join()
return bpy.context.active_object, armature
return (meshes[0] if meshes else None), armature
# --------------------------------------------------------------------------
# Fitting (Surface Deform + Shrinkwrap fallback)
# --------------------------------------------------------------------------
def try_surface_deform(clothing, body_surface):
"""Bind + apply Surface Deform via a headless context override. Returns bool."""
clothing.select_set(True)
set_active(clothing)
mod = clothing.modifiers.new("SurfaceDeformFit", 'SURFACE_DEFORM')
mod.target = body_surface
mod.falloff = SD_FALLOFF
mod_name = mod.name
try:
with bpy.context.temp_override(
active_object=clothing, object=clothing, selected_objects=[clothing]
):
bpy.ops.object.surfacedeform_bind(modifier=mod_name)
except Exception as exc:
log(f" SD bind EXCEPTION: {exc}")
clothing.modifiers.remove(mod)
return False
if not mod.is_bound:
log(" SD bind did not complete (is_bound=False)")
clothing.modifiers.remove(mod)
return False
deselect_all()
clothing.select_set(True)
set_active(clothing)
bpy.ops.object.modifier_apply(modifier=mod_name)
return True
def shrinkwrap_fallback(clothing, body_surface):
log(" Shrinkwrap fallback (SD bind failed)")
sw = clothing.modifiers.new("ShrinkwrapFit", 'SHRINKWRAP')
sw.target = body_surface
sw.wrap_method = 'NEAREST_SURFACEPOINT'
sw.wrap_mode = 'ON_SURFACE'
sw.offset = SHRINKWRAP_OFFSET
deselect_all()
clothing.select_set(True)
set_active(clothing)
bpy.ops.object.modifier_apply(modifier=sw.name)
def transfer_weights(clothing, body_surface):
"""Data Transfer VGROUP_WEIGHTS from the fitted body (fit_outfits :160-186)."""
deselect_all()
set_active(clothing)
clothing.select_set(True)
dt = clothing.modifiers.new("WeightTransfer", 'DATA_TRANSFER')
dt.object = body_surface
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.vert_mapping = 'POLYINTERP_NEAREST'
dt.layers_vgroup_select_src = 'ALL'
dt.layers_vgroup_select_dst = 'NAME'
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
def normalize_weights(clothing):
deselect_all()
set_active(clothing)
clothing.select_set(True)
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
bpy.ops.object.mode_set(mode='OBJECT')
def retarget_armature(clothing, new_armature):
has_arm = False
for mod in clothing.modifiers:
if mod.type == 'ARMATURE':
mod.object = new_armature
has_arm = True
if not has_arm:
mod = clothing.modifiers.new("Armature", 'ARMATURE')
mod.object = new_armature
clothing.parent = new_armature
clothing.matrix_parent_inverse = new_armature.matrix_world.inverted()
def apply_solidify(clothing, thickness):
if thickness <= 0.0:
return
deselect_all()
clothing.select_set(True)
set_active(clothing)
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')
sol = clothing.modifiers.new("Solidify", 'SOLIDIFY')
sol.thickness = thickness
sol.offset = 1.0
sol.use_rim = True
bpy.ops.object.modifier_apply(modifier=sol.name)
def export_variant(clothing, armature, out_path):
deselect_all()
clothing.select_set(True)
armature.select_set(True)
set_active(armature)
bpy.ops.export_scene.gltf(
filepath=out_path,
export_format='GLB',
use_selection=True,
export_apply=False, # keep armature modifier for skinning
export_animations=False,
export_skins=True, # <-- the whole point of G1
export_yup=True,
export_texcoords=True,
export_normals=True,
export_materials='EXPORT',
export_image_format='AUTO',
)
# --------------------------------------------------------------------------
# Per-body processing
# --------------------------------------------------------------------------
def process_body(body_type, reference_glb, bodies_dir, output_dir, solidify_mm):
out_path = os.path.join(output_dir, f"{body_type}.glb")
log(f"=== {body_type} ===")
if body_type == REFERENCE_BODY:
shutil.copy2(reference_glb, out_path)
log(" reference body — direct copy")
return {"body_type": body_type, "status": "ok", "method": "copy"}
clear_scene()
clothing, _ref_arm = import_reference_garment(reference_glb)
if clothing is None:
return {"body_type": body_type, "status": "error", "error": "no garment mesh"}
body_surface, armature = build_body(bodies_dir, body_type)
if body_surface is None:
return {"body_type": body_type, "status": "error", "error": "no body surface"}
if try_surface_deform(clothing, body_surface):
method = "surface_deform"
else:
shrinkwrap_fallback(clothing, body_surface)
method = "shrinkwrap"
transfer_weights(clothing, body_surface)
normalize_weights(clothing)
retarget_armature(clothing, armature)
apply_solidify(clothing, solidify_mm)
# Drop the body surface so only garment + armature export.
bpy.data.objects.remove(body_surface, do_unlink=True)
export_variant(clothing, armature, out_path)
log(f" exported [{method}] -> {os.path.basename(out_path)}")
return {"body_type": body_type, "status": "ok", "method": method}
# --------------------------------------------------------------------------
# Self-check (headless smoke test): refit peasant_tunic to average_f
# --------------------------------------------------------------------------
def self_check(bodies_dir):
"""Prove the SD bind + weight flow + skinned export path runs headless.
Builds average_f's body surface, binds a trivial one-quad plane to it, and
confirms the export produces JOINTS_0/WEIGHTS_0 accessors. Non-fatal probe.
"""
import struct
clear_scene()
bpy.ops.mesh.primitive_plane_add(size=0.3, location=(0, 0.1, 1.2))
plane = bpy.context.active_object
body_surface, armature = build_body(bodies_dir, "average_f")
if body_surface is None:
log("self-check: no average_f body — SKIP")
return
ok = try_surface_deform(plane, body_surface)
log(f"self-check: SD bind {'ok' if ok else 'fell back'}")
if not ok:
shrinkwrap_fallback(plane, body_surface)
transfer_weights(plane, body_surface)
normalize_weights(plane)
retarget_armature(plane, armature)
bpy.data.objects.remove(body_surface, do_unlink=True)
out = os.path.join(bpy.app.tempdir, "selfcheck.glb")
export_variant(plane, armature, out)
with open(out, 'rb') as f:
f.read(12)
clen = struct.unpack('<I', f.read(4))[0]
f.read(4)
j = json.loads(f.read(clen))
attrs = set()
for m in j.get("meshes", []):
for pr in m["primitives"]:
attrs |= set(pr["attributes"].keys())
has_skin = "JOINTS_0" in attrs and "WEIGHTS_0" in attrs
log(f"self-check: exported attrs={sorted(attrs)} skinned={has_skin}")
log(f"self-check: {'PASS' if has_skin else 'FAIL'}")
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if "--self-check" in argv:
# <bodies_dir> is the first positional in self-check mode
pos = [a for a in argv if not a.startswith("--")]
bodies_dir = pos[0] if pos else "client/assets/characters/bodies"
self_check(bodies_dir)
return
if len(argv) < 3:
print("Usage: -- <reference_glb> <bodies_dir> <output_dir> "
"[--only a,b,c] [--solidify MM]")
sys.exit(1)
reference_glb, bodies_dir, output_dir = argv[0], argv[1], argv[2]
only = None
if "--only" in argv:
only = [s.strip() for s in argv[argv.index("--only") + 1].split(",")]
solidify_mm = 0.0
if "--solidify" in argv:
solidify_mm = float(argv[argv.index("--solidify") + 1])
if not os.path.isfile(reference_glb):
print(f"ERROR: reference not found: {reference_glb}")
sys.exit(1)
os.makedirs(output_dir, exist_ok=True)
types = only if only else BODY_TYPES
log(f"reference={reference_glb}")
log(f"bodies={bodies_dir}")
log(f"output={output_dir}")
log(f"types={types} solidify={solidify_mm*1000:.0f}mm")
results = []
for bt in types:
if bt != REFERENCE_BODY and not os.path.isdir(os.path.join(bodies_dir, bt)):
results.append({"body_type": bt, "status": "skipped",
"error": "body dir missing"})
continue
try:
results.append(process_body(bt, reference_glb, bodies_dir,
output_dir, solidify_mm))
except Exception as exc:
log(f" ERROR {bt}: {exc}")
results.append({"body_type": bt, "status": "error", "error": str(exc)})
ok = [r for r in results if r["status"] == "ok"]
sd = len([r for r in ok if r.get("method") == "surface_deform"])
sw = len([r for r in ok if r.get("method") == "shrinkwrap"])
cp = len([r for r in ok if r.get("method") == "copy"])
log("=" * 50)
for r in results:
log(f" {r['body_type']:12s} {r['status'].upper():8s} "
f"{r.get('method', r.get('error', ''))}")
log(f"OK={len(ok)} (surface_deform={sd} shrinkwrap={sw} copy={cp})")
if sw:
log(f"WARNING: {sw} variant(s) used Shrinkwrap — verify extremities at zoom")
with open(os.path.join(output_dir, "pipeline_log.json"), 'w') as f:
json.dump({
"reference": os.path.basename(reference_glb),
"bodies_dir": bodies_dir,
"variants": {r["body_type"]: {
"status": r["status"],
"method": r.get("method"),
"error": r.get("error"),
} for r in results},
}, f, indent=2)
log("wrote pipeline_log.json")
if any(r["status"] == "error" for r in results):
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,58 @@
"""
blender_compare_bones.py
Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- <file_a.glb> <file_b.glb>
Compares bone names between two GLB/GLTF files. Reports missing or extra bones.
"""
import sys
import bpy
def get_bones(path: str) -> set:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=path)
for obj in bpy.context.scene.objects:
if obj.type == 'ARMATURE':
return {b.name for b in obj.data.bones}
return set()
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- <a.glb> <b.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Need two GLB/GLTF paths.")
sys.exit(1)
path_a, path_b = args[0], args[1]
print(f"Loading A: {path_a}")
bones_a = get_bones(path_a)
print(f"A has {len(bones_a)} bones")
print(f"Loading B: {path_b}")
bones_b = get_bones(path_b)
print(f"B has {len(bones_b)} bones")
only_in_a = bones_a - bones_b
only_in_b = bones_b - bones_a
shared = bones_a & bones_b
print(f"Shared: {len(shared)}")
if only_in_a:
print(f"Only in A ({len(only_in_a)}):")
for name in sorted(only_in_a):
print(f" - {name}")
if only_in_b:
print(f"Only in B ({len(only_in_b)}):")
for name in sorted(only_in_b):
print(f" + {name}")
if not only_in_a and not only_in_b:
print("BONE_MATCH=OK")
else:
print("BONE_MATCH=MISMATCH")
@@ -0,0 +1,292 @@
"""
blender_create_clothing_refs.py
Usage:
tooling/blender --background --python tooling/blender_create_clothing_refs.py -- \\
<bodies_dir> <clothing_output_dir>
Creates v0.2 placeholder reference clothing meshes for all 5 initial clothing items.
Meshes are authored on average_m by importing the relevant body segments, joining
them, and offsetting vertices outward along normals to simulate clothing thickness.
This produces placeholder-quality geometry only not final art. The geometry reads
correctly at gameplay zoom and is sufficient to validate the Surface Deform pipeline
and compositor integration.
Arguments:
bodies_dir Directory with one subdir per body type (e.g. client/assets/characters/bodies/)
clothing_output_dir Root directory for clothing output (e.g. client/assets/characters/clothing/)
Output per item in <clothing_output_dir>/<item_id>/:
reference.glb -- placeholder clothing mesh on average_m geometry
Items produced:
coveralls_basic -- full-body work suit
jacket_utility -- upper body outerwear
pants_cargo -- lower body
shirt_henley -- upper body inner
boots_work -- foot slot
Decisions: D-162 (clothing pre-baked per body type via Surface Deform)
"""
import sys
import os
import bpy
import bmesh
REFERENCE_BODY = "average_m"
# Each item defines:
# segments -- body segment GLBs (from average_m) to import and join
# thickness -- outward vertex offset in metres (clothing thickness simulation)
CLOTHING_ITEMS = {
"coveralls_basic": {
"description": "Full-body work suit",
"segments": [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
"seg_hand_l", "seg_hand_r",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
"seg_foot_l", "seg_foot_r",
],
"thickness": 0.006,
},
"jacket_utility": {
"description": "Upper body outerwear",
"segments": [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
],
"thickness": 0.008, # slightly thicker for outerwear
},
"pants_cargo": {
"description": "Lower body cargo trousers",
"segments": [
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
],
"thickness": 0.006,
},
"shirt_henley": {
"description": "Upper body inner shirt",
"segments": [
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
],
"thickness": 0.004, # thinner for inner layer
},
"boots_work": {
"description": "Work boots (foot slot)",
"segments": [
"seg_foot_l", "seg_foot_r",
"seg_leg_lower_l", "seg_leg_lower_r", # boot shaft reaches up the lower leg
],
"thickness": 0.010, # thicker for boots
},
}
def clear_scene():
"""Remove all objects and purge orphan data."""
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
bpy.ops.outliner.orphans_purge(do_recursive=True)
def import_glb(path):
"""Import a GLB file. Returns newly added objects."""
before = set(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=path)
return [o for o in bpy.context.scene.objects if o not in before]
def offset_vertices_along_normals(obj, thickness):
"""
Move each vertex outward along its computed normal by `thickness` metres.
Operates directly on mesh data -- no operators, reliable in headless mode.
"""
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.verts.ensure_lookup_table()
# Ensure normals are up to date
bm.normal_update()
for v in bm.verts:
v.co += v.normal * thickness
bm.to_mesh(obj.data)
bm.free()
obj.data.update()
def export_glb(obj, output_path, armature=None):
"""Export a mesh (and optionally its armature) as GLB with skinning data."""
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
if armature:
armature.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.export_scene.gltf(
filepath=output_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO',
export_texcoords=True,
export_normals=True,
export_skins=armature is not None,
export_materials='EXPORT',
)
def create_reference_mesh(item_id, config, bodies_dir, output_dir):
"""
Create the reference.glb for one clothing item on average_m geometry.
Preserves bone weights from body segments so clothing deforms with the skeleton.
Returns True on success, False on error.
"""
print(f"\n{'='*60}")
print(f" Item: {item_id}{config['description']}")
clear_scene()
average_m_dir = os.path.join(bodies_dir, REFERENCE_BODY)
imported_meshes = []
imported_armatures = []
missing_segments = []
for seg_name in config["segments"]:
seg_path = os.path.join(average_m_dir, f"{seg_name}.glb")
if not os.path.isfile(seg_path):
missing_segments.append(seg_name)
continue
objs = import_glb(seg_path)
meshes = [o for o in objs if o.type == 'MESH']
armatures = [o for o in objs if o.type == 'ARMATURE']
imported_meshes.extend(meshes)
imported_armatures.extend(armatures)
if missing_segments:
print(f" NOTE: Missing segments (skipped): {', '.join(missing_segments)}")
if not imported_meshes:
print(f" ERROR: No segment meshes could be imported for {item_id}")
return False
print(f" Imported {len(imported_meshes)} segments, "
f"{len(imported_armatures)} armatures "
f"({len(missing_segments)} missing)")
# Use the first armature as the canonical one — all segments share the same rig
canonical_armature = imported_armatures[0] if imported_armatures else None
# Re-parent all meshes to the canonical armature (preserving bone weights)
if canonical_armature:
for m in imported_meshes:
# Clear any existing parent
m.parent = None
m.matrix_world = m.matrix_world # preserve world transform
# Set parent to canonical armature with Armature modifier
m.parent = canonical_armature
m.parent_type = 'OBJECT'
# Ensure Armature modifier exists pointing to canonical armature
has_armature_mod = False
for mod in m.modifiers:
if mod.type == 'ARMATURE':
mod.object = canonical_armature
has_armature_mod = True
if not has_armature_mod:
mod = m.modifiers.new(name="Armature", type='ARMATURE')
mod.object = canonical_armature
# Remove duplicate armatures (keep only canonical)
for arm in imported_armatures[1:]:
bpy.data.objects.remove(arm, do_unlink=True)
# Join all segments into one mesh (vertex groups / bone weights are preserved by join)
bpy.ops.object.select_all(action='DESELECT')
for m in imported_meshes:
if m.name in bpy.data.objects:
m.select_set(True)
bpy.context.view_layer.objects.active = imported_meshes[0]
if len(imported_meshes) > 1:
bpy.ops.object.join()
clothing_obj = bpy.context.active_object
clothing_obj.name = f"ref_{item_id}"
vertex_count_before = len(clothing_obj.data.vertices)
print(f" Mesh vertices: {vertex_count_before}")
print(f" Vertex groups (bone weights): {len(clothing_obj.vertex_groups)}")
# Offset vertices outward to simulate clothing thickness
thickness = config["thickness"]
offset_vertices_along_normals(clothing_obj, thickness)
print(f" Applied {thickness*1000:.1f}mm outward offset")
# Export with armature so clothing is skinned
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "reference.glb")
export_glb(clothing_obj, output_path, armature=canonical_armature)
print(f" Exported: {output_path}")
return True
# -------------------------------------------------------------------------
# Entry point
# -------------------------------------------------------------------------
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python "
"tooling/blender_create_clothing_refs.py -- "
"<bodies_dir> <clothing_output_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide <bodies_dir> and <clothing_output_dir>")
sys.exit(1)
bodies_dir = args[0]
clothing_output_dir = args[1]
# Preflight: check average_m dir exists
avg_m_dir = os.path.join(bodies_dir, REFERENCE_BODY)
if not os.path.isdir(avg_m_dir):
print(f"ERROR: Reference body directory not found: {avg_m_dir}")
sys.exit(1)
print("\nClothing Reference Mesh Creator (v0.2 placeholder)")
print(f" Bodies dir: {bodies_dir}")
print(f" Output dir: {clothing_output_dir}")
print(f" Reference: {REFERENCE_BODY}")
print(f" Items: {len(CLOTHING_ITEMS)}")
results = {}
for item_id, config in CLOTHING_ITEMS.items():
item_output_dir = os.path.join(clothing_output_dir, item_id)
success = create_reference_mesh(item_id, config, bodies_dir, item_output_dir)
results[item_id] = success
# Summary
print(f"\n{'='*60}")
print("=== Clothing reference creation complete ===")
ok_items = [k for k, v in results.items() if v]
fail_items = [k for k, v in results.items() if not v]
for item_id in CLOTHING_ITEMS:
status = "OK" if results[item_id] else "FAILED"
print(f" {item_id:20s} {status}")
print(f"\n OK: {len(ok_items)} FAILED: {len(fail_items)}")
if fail_items:
sys.exit(1)
@@ -0,0 +1,93 @@
"""
blender_extract_armature.py
Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- <input.gltf> <output.glb>
Loads a Quaternius GLTF file, strips all mesh objects and animation data,
and exports only the armature as a GLB skeleton file.
Requirements:
- 65-bone hierarchy preserved
- No mesh geometry
- No animation data
- Y-up, -Z forward (glTF default)
- 1 unit = 1 meter (Quaternius convention)
"""
import sys
import bpy
def extract_armature(input_path: str, output_path: str) -> None:
# Clear the default scene
bpy.ops.wm.read_factory_settings(use_empty=True)
# Import the GLTF/GLB
print(f"Loading: {input_path}")
bpy.ops.import_scene.gltf(filepath=input_path)
# Report what was imported
all_objects = list(bpy.context.scene.objects)
print(f"Imported {len(all_objects)} objects:")
for obj in all_objects:
print(f" {obj.name} ({obj.type})")
# Find armature objects
armatures = [obj for obj in all_objects if obj.type == 'ARMATURE']
if not armatures:
print("ERROR: No armature found in the imported file.")
sys.exit(1)
armature = armatures[0]
print(f"Armature: {armature.name}{len(armature.data.bones)} bones")
if len(armature.data.bones) < 60:
print(f"WARNING: Expected ~65 bones, found {len(armature.data.bones)}. Check compatibility.")
# Remove all non-armature objects (meshes, lights, cameras, empties)
bpy.ops.object.select_all(action='DESELECT')
for obj in all_objects:
if obj.type != 'ARMATURE':
obj.select_set(True)
bpy.ops.object.delete()
# Remove all animation data (skeleton only, no poses)
if armature.animation_data:
armature.animation_data_clear()
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
# Select only the armature for export
bpy.ops.object.select_all(action='DESELECT')
armature.select_set(True)
bpy.context.view_layer.objects.active = armature
# Export as GLB — skeleton only, no animations, no meshes
print(f"Exporting to: {output_path}")
bpy.ops.export_scene.gltf(
filepath=output_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_skins=True,
export_yup=True,
)
# Verify: report bone count and names
bones = sorted(armature.data.bones, key=lambda b: b.name)
print(f"Export complete. {len(bones)} bones:")
for bone in bones:
print(f" {bone.name}")
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- <input.gltf> <output.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide input GLTF/GLB path and output GLB path.")
sys.exit(1)
extract_armature(args[0], args[1])
@@ -0,0 +1,20 @@
"""
blender_inspect_body.py
Usage: tooling/blender --background --python tooling/blender_inspect_body.py -- <input.gltf>
Lists all mesh objects in a FullBody GLTF: names, vertex counts, vertex group counts.
"""
import sys
import bpy
if __name__ == "__main__":
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=args[0])
print("Scene objects:")
for obj in sorted(bpy.context.scene.objects, key=lambda o: o.name):
if obj.type == 'MESH':
print(f" MESH: {obj.name!r} verts={len(obj.data.vertices)} vgroups={len(obj.vertex_groups)}")
else:
print(f" {obj.type}: {obj.name!r}")
@@ -0,0 +1,25 @@
"""
blender_inspect_vgroups.py
Usage: tooling/blender --background --python tooling/blender_inspect_vgroups.py -- <input.gltf>
Lists vertex groups in a GLTF full-body mesh. Used to verify bone name coverage
for the segmentation script.
"""
import sys
import bpy
if __name__ == "__main__":
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=args[0])
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and obj.vertex_groups:
print(f"MESH: {obj.name}{len(obj.vertex_groups)} vertex groups, {len(obj.data.vertices)} vertices")
for vg in sorted(obj.vertex_groups, key=lambda x: x.name):
print(f" VG: {vg.name}")
break
else:
print("No skinned mesh found.")
for obj in bpy.context.scene.objects:
print(f" {obj.name} ({obj.type})")
@@ -0,0 +1,36 @@
"""
blender_list_animations.py
Usage: tooling/blender --background --python tooling/blender_list_animations.py -- <input.glb>
Lists all animation actions in a GLB file.
"""
import sys
import bpy
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_list_animations.py -- <input.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 1:
print("ERROR: Provide a GLB/GLTF path.")
sys.exit(1)
input_path = args[0]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=input_path)
actions = list(bpy.data.actions)
print("ACTIONS_COUNT=" + str(len(actions)))
for action in sorted(actions, key=lambda a: a.name):
print("ACTION: " + action.name)
# Also report armature bone count if present
for obj in bpy.context.scene.objects:
if obj.type == 'ARMATURE':
print("ARMATURE_BONES=" + str(len(obj.data.bones)))
@@ -0,0 +1,37 @@
"""
blender_list_bones.py
Usage: tooling/blender --background --python tooling/blender_list_bones.py -- <input.glb>
Lists all bone names in a GLB/GLTF armature, sorted alphabetically.
Used to verify bone naming after the January 2026 Quaternius naming update.
"""
import sys
import bpy
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_list_bones.py -- <input.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 1:
print("ERROR: Provide a GLB/GLTF path.")
sys.exit(1)
input_path = args[0]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=input_path)
for obj in bpy.context.scene.objects:
if obj.type == 'ARMATURE':
print(f"ARMATURE: {obj.name}{len(obj.data.bones)} bones")
for b in sorted(obj.data.bones, key=lambda x: x.name):
print("BONE: " + b.name)
break
else:
print("ERROR: No armature found in the imported file.")
sys.exit(1)
@@ -0,0 +1,140 @@
"""
blender_process_bodies.py
Usage: tooling/blender --background --python tooling/blender_process_bodies.py -- <source_dir> <output_base_dir>
Drives the full body segmentation pipeline for all 11 body types.
Calls blender_segment_body.py logic inline (same Blender session, one pass per body type).
Source GLTF files (pre-exported from Source tier, Godot - UE format):
<source_dir>/Regular_Male_FullBody.gltf -> average_m (18 segs)
<source_dir>/Regular_Female_FullBody.gltf -> average_f (18 segs)
<source_dir>/Superhero_Male_FullBody.gltf -> muscular_m (18 segs)
<source_dir>/Superhero_Female_FullBody.gltf -> muscular_f (18 segs)
<source_dir>/Teen_Male_FullBody.gltf -> teen_m (18 segs)
<source_dir>/Teen_Female_FullBody.gltf -> teen_f (18 segs)
Fork body types (vertex-level scale applied before segmentation):
Regular_Male + scale (0.82, 1.0, 0.88) -> thin_m
Regular_Female + scale (0.82, 1.0, 0.88) -> thin_f
Regular_Male + scale (1.20, 1.08, 1.0) -> heavy_m
Regular_Female + scale (1.20, 1.08, 1.0) -> heavy_f
Teen_Male + scale (0.75, 0.72, 0.75) -> child [gender-neutral]
Fork body types are approximate (auto-generated from mesh scaling). They produce
visually distinct silhouettes but may need artist refinement for final quality.
A README is placed in each fork directory marking this status.
Output: <output_base_dir>/{body_type}/seg_{name}.glb (18 files × 11 types = 198 GLBs)
Decisions: D-159 (11 body types), D-160 (18 segments), D-164 (Source .blends as starting point)
"""
import sys
import os
# We import the segmentation logic from blender_segment_body.py which must be
# in the same directory as this script.
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)
from blender_segment_body import segment_body
# (gltf_filename, body_type_key, scale, is_fork)
BODY_MANIFEST = [
# --- Direct body types (segment as-is) ---
("Regular_Male_FullBody.gltf", "average_m", (1.0, 1.0, 1.0), False),
("Regular_Female_FullBody.gltf", "average_f", (1.0, 1.0, 1.0), False),
("Superhero_Male_FullBody.gltf", "muscular_m", (1.0, 1.0, 1.0), False),
("Superhero_Female_FullBody.gltf","muscular_f", (1.0, 1.0, 1.0), False),
("Teen_Male_FullBody.gltf", "teen_m", (1.0, 1.0, 1.0), False),
("Teen_Female_FullBody.gltf", "teen_f", (1.0, 1.0, 1.0), False),
# --- Fork body types (auto-scaled from source) ---
# thin: narrow/ectomorph — narrower X, slightly shorter Z
("Regular_Male_FullBody.gltf", "thin_m", (0.82, 1.0, 0.88), True),
("Regular_Female_FullBody.gltf", "thin_f", (0.82, 1.0, 0.88), True),
# heavy: wide/endomorph — wider X, slightly taller Y
("Regular_Male_FullBody.gltf", "heavy_m", (1.20, 1.08, 1.0), True),
("Regular_Female_FullBody.gltf", "heavy_f", (1.20, 1.08, 1.0), True),
# child: gender-neutral, forked from Teen Male, scaled down
("Teen_Male_FullBody.gltf", "child", (0.75, 0.72, 0.75), True),
]
FORK_README = """\
# Fork body type — auto-generated from mesh scaling
This directory contains **{body_type}** body segments, generated by applying
a proportional mesh scale to the source body ({source}):
Scale: ({sx:.2f}, {sy:.2f}, {sz:.2f})
These segments are APPROXIMATE. They produce a visually distinct silhouette
but are not artist-authored from scratch. Review the proportions and refine
the base mesh manually if the auto-scale result is not satisfactory.
Status: auto-generated, needs artist review
Sprint 28 visual team
"""
def write_fork_readme(output_dir, body_type, source, scale):
sx, sy, sz = scale
readme_path = os.path.join(output_dir, "README.md")
with open(readme_path, 'w') as f:
f.write(FORK_README.format(body_type=body_type, source=source, sx=sx, sy=sy, sz=sz))
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_process_bodies.py -- <source_dir> <output_base_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide <source_dir> and <output_base_dir>")
sys.exit(1)
source_dir = args[0]
output_base_dir = args[1]
# Preflight: check ALL source files exist before starting any processing,
# so all missing files are reported at once rather than failing mid-loop.
unique_sources = {gltf_filename for (gltf_filename, _, _, _) in BODY_MANIFEST}
missing = [
os.path.join(source_dir, f)
for f in sorted(unique_sources)
if not os.path.exists(os.path.join(source_dir, f))
]
if missing:
print("\nERROR: Missing source GLTF files:")
for path in missing:
print(f" {path}")
sys.exit(1)
results = []
for (gltf_filename, body_type, scale, is_fork) in BODY_MANIFEST:
gltf_path = os.path.join(source_dir, gltf_filename)
output_dir = os.path.join(output_base_dir, body_type)
print(f"\n{'='*60}")
print(f" Body type: {body_type} {'[FORK]' if is_fork else '[DIRECT]'}")
print(f" Source: {gltf_filename}")
if is_fork:
print(f" Scale: {scale}")
exported, skipped = segment_body(gltf_path, output_dir, scale)
if is_fork:
write_fork_readme(output_dir, body_type, gltf_filename, scale)
results.append((body_type, exported, skipped, is_fork))
# Summary
print(f"\n{'='*60}")
print("=== Body type segmentation complete ===")
total_exported = 0
for body_type, exported, skipped, is_fork in results:
flag = " [FORK]" if is_fork else ""
print(f" {body_type}{flag}: {len(exported)} exported, {len(skipped)} skipped")
total_exported += len(exported)
print(f" Total GLBs: {total_exported}")
@@ -0,0 +1,248 @@
"""
blender_process_hair.py
Usage: tooling/blender --background --python tooling/blender_process_hair.py -- <source_base_dir> <hair_out_dir> <facial_hair_out_dir> <eyebrows_out_dir>
Converts Quaternius Source tier hairstyle GLTF files (rigged to Head bone) into
production GLBs with mask PNG sidecars.
Source base dir: .../Hairstyles/Rigged to Head Bone/glTF (Godot -Unreal)/
Output:
hair/ {key}.glb + {key}_mask.png (hair styles)
facial_hair/ {key}.glb + {key}_mask.png (beard, moustache, mutton_chops)
eyebrows/ {key}.glb (no mask used as-is, tinted by shader)
Naming conventions from architecture doc (D-164):
Quaternius name -> file key
Hair_Bob -> bob
Hair_Buns -> buns
Hair_BuzzedFemale -> buzzed_female
Hair_Long -> long
Hair_LongDreads -> long_dreads
Hair_Ponytail_2 -> ponytail_f
Hair_Balding -> balding
Hair_Buzzed -> buzzed
Hair_Dreads -> dreads
Hair_Mohawk -> mohawk
Hair_Ponytail -> ponytail
Hair_SimpleParted -> simple_parted
Hair_SlickBack -> slick_back
Hair_Beard -> beard [facial_hair/]
Hair_Moustache -> moustache [facial_hair/]
Hair_MuttonChops -> mutton_chops [facial_hair/]
Eyebrows_Female -> female [eyebrows/]
Eyebrows_Regular -> regular [eyebrows/]
Eyebrows_Teen -> teen [eyebrows/]
Eyebrows_Thick -> thick [eyebrows/]
Also produces bald.glb (minimal empty mesh placeholder for 'no hair' slot).
"""
import sys
import os
import bpy
# (source_gltf_relative_to_base, output_key, output_dir_tag)
# dir_tag: "hair", "facial_hair", "eyebrows"
HAIR_MANIFEST = [
# --- Female hairstyles ---
("Female/Hair_Bob.gltf", "bob", "hair"),
("Female/Hair_Buns.gltf", "buns", "hair"),
("Female/Hair_BuzzedFemale.gltf", "buzzed_female", "hair"),
("Female/Hair_Long.gltf", "long", "hair"),
("Female/Hair_LongDreads.gltf", "long_dreads", "hair"),
("Female/Hair_Ponytail_2.gltf", "ponytail_f", "hair"),
# --- Male hairstyles ---
("Male/Hair_Balding.gltf", "balding", "hair"),
("Male/Hair_Buzzed.gltf", "buzzed", "hair"),
("Male/Hair_Dreads.gltf", "dreads", "hair"),
("Male/Hair_Mohawk.gltf", "mohawk", "hair"),
("Male/Hair_Ponytail.gltf", "ponytail", "hair"),
("Male/Hair_SimpleParted.gltf", "simple_parted", "hair"),
("Male/Hair_SlickBack.gltf", "slick_back", "hair"),
# --- Facial hair ---
("Male/Hair_Beard.gltf", "beard", "facial_hair"),
("Male/Hair_Moustache.gltf", "moustache", "facial_hair"),
("Male/Hair_MuttonChops.gltf", "mutton_chops", "facial_hair"),
# --- Eyebrows ---
("Female/Eyebrows_Female.gltf", "female", "eyebrows"),
("Female/Eyebrows_Teen.gltf", "teen", "eyebrows"),
("Male/Eyebrows_Regular.gltf", "regular", "eyebrows"),
("Male/Eyebrows_Thick.gltf", "thick", "eyebrows"),
]
def convert_gltf_to_glb(gltf_path: str, glb_path: str, solidify: bool = False) -> None:
"""Import a GLTF file and re-export as GLB with embedded textures."""
bpy.ops.wm.read_factory_settings(use_empty=True)
print(f" Loading: {os.path.basename(gltf_path)}")
bpy.ops.import_scene.gltf(filepath=gltf_path)
objects = list(bpy.context.scene.objects)
meshes = [o for o in objects if o.type == 'MESH']
arms = [o for o in objects if o.type == 'ARMATURE']
print(f" Objects: {len(meshes)} meshes, {len(arms)} armatures")
# Strip animation data (hair has no runtime animation)
for obj in objects:
if obj.animation_data:
obj.animation_data_clear()
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
# Solidify hair meshes — gives flat hair cards 3D thickness so they don't
# clip through the scalp. Makes hair look like a hair-shaped shell.
# Only applied to scalp hair, not eyebrows or facial hair.
HAIR_THICKNESS = 0.020 # 20mm outward thickness
if solidify:
for m in meshes:
# Skip icospheres and non-hair utility objects
if m.name.startswith("Icosphere") or len(m.data.vertices) < 50:
continue
bpy.context.view_layer.objects.active = m
m.select_set(True)
# Recalculate normals to ensure consistent outward direction
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')
# Apply solidify
sol_mod = m.modifiers.new(name="Solidify", type='SOLIDIFY')
sol_mod.thickness = HAIR_THICKNESS
sol_mod.offset = 1.0 # grow outward only
sol_mod.use_rim = True # fill edges for closed shell
bpy.ops.object.modifier_apply(modifier=sol_mod.name)
m.select_set(False)
print(f" Solidified: {m.name} ({HAIR_THICKNESS*1000:.0f}mm outward)")
# Keep armature parenting and skin data intact — the Godot compositor
# will load the skinned mesh and add it to the shared skeleton at runtime.
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(
filepath=glb_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO',
export_texcoords=True,
export_normals=True,
export_skins=True,
export_materials='EXPORT',
)
size = os.path.getsize(glb_path)
print(f" GLB exported: {os.path.basename(glb_path)} ({size:,} bytes)")
def make_mask_png(mask_path: str, width: int = 64, height: int = 64) -> None:
"""Generate a solid-white mask PNG (entire mesh = tintable region).
Solid white = fully tintable. Small size is fine the shader just samples
white everywhere. The mask format supports greyscale bands for multi-region
recoloring when needed in future.
"""
import struct
import zlib
# Build a minimal white PNG manually — no Blender image API issues
def make_png(w, h):
def chunk(ctype, data):
c = ctype + data
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
header = b'\x89PNG\r\n\x1a\n'
ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 0, 0, 0, 0)) # 8-bit greyscale
raw = b''
for _ in range(h):
raw += b'\x00' + b'\xff' * w # filter byte + white pixels
idat = chunk(b'IDAT', zlib.compress(raw))
iend = chunk(b'IEND', b'')
return header + ihdr + idat + iend
with open(mask_path, 'wb') as f:
f.write(make_png(width, height))
print(f" Mask saved: {os.path.basename(mask_path)} ({width}x{height} white)")
def make_bald_placeholder(glb_path: str) -> None:
"""Create a minimal empty GLB for the 'no hair' slot."""
bpy.ops.wm.read_factory_settings(use_empty=True)
# Create a single-vertex mesh with no faces (zero-poly placeholder)
mesh_data = bpy.data.meshes.new("bald")
mesh_data.from_pydata([(0.0, 0.0, 0.0)], [], [])
mesh_data.update()
obj = bpy.data.objects.new("bald", mesh_data)
bpy.context.collection.objects.link(obj)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(
filepath=glb_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO',
export_materials='EXPORT',
)
size = os.path.getsize(glb_path)
print(f" bald.glb placeholder ({size:,} bytes)")
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_process_hair.py -- <source_base_dir> <hair_out_dir> <facial_hair_out_dir> <eyebrows_out_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 4:
print("ERROR: Provide source_base_dir, hair_out_dir, facial_hair_out_dir, eyebrows_out_dir")
sys.exit(1)
source_base = args[0]
hair_out = args[1]
fhair_out = args[2]
eyebrows_out = args[3]
out_dirs = {"hair": hair_out, "facial_hair": fhair_out, "eyebrows": eyebrows_out}
for d in out_dirs.values():
os.makedirs(d, exist_ok=True)
results = []
errors = []
for (gltf_rel, key, tag) in HAIR_MANIFEST:
gltf_path = os.path.join(source_base, gltf_rel)
out_dir = out_dirs[tag]
if not os.path.exists(gltf_path):
errors.append(f" MISSING: {gltf_path}")
continue
print(f"\n[{tag}] {key}")
glb_path = os.path.join(out_dir, key + ".glb")
# Only solidify scalp hair — not facial hair or eyebrows
convert_gltf_to_glb(gltf_path, glb_path, solidify=(tag == "hair"))
# Eyebrows: no mask (tinted directly by shader, no recolor mask needed)
if tag != "eyebrows":
mask_path = os.path.join(out_dir, key + "_mask.png")
make_mask_png(mask_path)
results.append((tag, key, os.path.getsize(glb_path)))
# bald.glb placeholder
print("\n[hair] bald (placeholder)")
bald_path = os.path.join(hair_out, "bald.glb")
make_bald_placeholder(bald_path)
results.append(("hair", "bald", os.path.getsize(bald_path)))
print("\n=== Hairstyle import complete ===")
for tag, key, size in results:
print(f" [{tag}] {key}.glb ({size:,} bytes)")
if errors:
print("\nMISSING FILES:")
for e in errors:
print(e)
sys.exit(1)
@@ -0,0 +1,146 @@
"""
blender_process_heads.py
Usage: tooling/blender --background --python tooling/blender_process_heads.py -- <source_dir> <output_dir>
Processes the 4 Quaternius OnlyHead .blend files into the head template library.
Source .blends (from <source_dir>):
Regular_Female_OnlyHead.blend -> head_001.glb + head_001_mask.png
Regular_Male_OnlyHead.blend -> head_002.glb + head_002_mask.png
Teen_Female_OnlyHead.blend -> head_003.glb + head_003_mask.png
Teen_Male_OnlyHead.blend -> head_004.glb + head_004_mask.png
Output: <output_dir>/ (typically client/assets/characters/heads/templates/)
Requirements (D-161, architecture doc):
- GLB: BoneAttachment3D-compatible, no animation data, embedded textures
- Scale: 1 unit = 1 meter, Y-up, -Z forward (glTF standard)
- Mask PNG: greyscale, white = tintable skin tone region (entire head surface = skin)
"""
import sys
import os
import bpy
HEAD_MAPPING = [
("Regular_Female_OnlyHead.blend", "head_001"),
("Regular_Male_OnlyHead.blend", "head_002"),
("Teen_Female_OnlyHead.blend", "head_003"),
("Teen_Male_OnlyHead.blend", "head_004"),
]
MASK_RESOLUTION = 1024 # Default; overridden if texture found in .blend
def process_head(blend_path: str, output_dir: str, head_id: str) -> None:
glb_path = os.path.join(output_dir, head_id + ".glb")
mask_path = os.path.join(output_dir, head_id + "_mask.png")
print(f"\n--- Processing: {os.path.basename(blend_path)}")
print(f" GLB -> {glb_path}")
print(f" Mask -> {mask_path}")
# Load the .blend file
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.open_mainfile(filepath=blend_path)
all_objects = list(bpy.context.scene.objects)
print(f" Imported {len(all_objects)} objects:")
for obj in all_objects:
print(f" {obj.name} ({obj.type})")
meshes = [obj for obj in all_objects if obj.type == 'MESH']
if not meshes:
print("ERROR: No mesh found in the .blend file")
sys.exit(1)
# Detect texture resolution for mask sizing.
# NOTE: This picks the first non-HDR texture found in bpy.data.images, which is
# fragile — iteration order is not guaranteed and multiple textures may be present.
# If no texture is found, falls back to MASK_RESOLUTION (512x512 default) so the
# mask is still generated at a sensible size rather than failing.
mask_w = mask_h = MASK_RESOLUTION
for img in bpy.data.images:
if img.size[0] > 0 and img.size[1] > 0 and not img.name.endswith('.hdr'):
print(f" Texture found: {img.name}{img.size[0]}x{img.size[1]}")
mask_w, mask_h = img.size[0], img.size[1]
break
else:
print(f" No texture found — using fallback mask size {mask_w}x{mask_h}")
# Strip all animation data (heads have no runtime animation — BoneAttachment3D moves them)
for obj in all_objects:
if obj.animation_data:
obj.animation_data_clear()
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
# Note: WGT-* rig widget meshes are automatically excluded by the GLTF exporter
# (they have no materials/geometry that exports). No deletion needed.
# Select all objects for export
bpy.ops.object.select_all(action='SELECT')
# Export GLB — embedded textures, no animations
print(" Exporting GLB...")
bpy.ops.export_scene.gltf(
filepath=glb_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO', # embed textures (AUTO embeds for GLB)
export_texcoords=True,
export_normals=True,
export_materials='EXPORT',
)
print(f" GLB exported ({os.path.getsize(glb_path):,} bytes)")
# Generate mask PNG — solid white: entire head surface is skin-tone tintable
# Format: greyscale (R channel), white = tintable, black = preserve
# Using RGBA internally; Blender PNG export honours this correctly.
mask_img = bpy.data.images.new(
name=head_id + "_mask",
width=mask_w,
height=mask_h,
alpha=False,
float_buffer=False,
)
# Fill with solid white (RGBA 1.0 per channel)
mask_img.pixels = [1.0] * (mask_w * mask_h * 4)
mask_img.colorspace_settings.name = 'Non-Color'
mask_img.file_format = 'PNG'
mask_img.filepath_raw = mask_path
mask_img.save()
print(f" Mask saved ({mask_w}x{mask_h}, solid white = all-skin)")
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_process_heads.py -- <source_dir> <output_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide <source_dir> and <output_dir>")
sys.exit(1)
source_dir = args[0]
output_dir = args[1]
os.makedirs(output_dir, exist_ok=True)
for blend_filename, head_id in HEAD_MAPPING:
blend_path = os.path.join(source_dir, blend_filename)
if not os.path.exists(blend_path):
print(f"ERROR: Source file not found: {blend_path}")
sys.exit(1)
process_head(blend_path, output_dir, head_id)
print("\n=== All 4 head templates processed ===")
for _, head_id in HEAD_MAPPING:
glb = os.path.join(output_dir, head_id + ".glb")
mask = os.path.join(output_dir, head_id + "_mask.png")
print(f" {head_id}.glb ({os.path.getsize(glb):,} bytes)")
print(f" {head_id}_mask.png ({os.path.getsize(mask):,} bytes)")
@@ -0,0 +1,104 @@
"""
blender_rebuild_forks.py
Usage: tooling/blender --background --python tooling/blender_rebuild_forks.py -- <source_dir> <output_base_dir> [body_type ...]
Rebuilds ONLY the fork body types (thin_m/f, heavy_m/f, child) through the same
segmentation pipeline that produced the healthy six (blender_segment_body.py),
using the T-1090 fix: the fork scale is applied to the mesh AND the embedded
armature rest pose so each segment stays internally consistent with its own
skeleton (required by the shared-skeleton compositor in character_visual.gd).
Sources (Source-tier Godot - UE exports):
Regular_Male_FullBody.gltf + scale (0.82, 1.0, 0.88) -> thin_m
Regular_Female_FullBody.gltf + scale (0.82, 1.0, 0.88) -> thin_f
Regular_Male_FullBody.gltf + scale (1.20, 1.08, 1.0) -> heavy_m
Regular_Female_FullBody.gltf + scale (1.20, 1.08, 1.0) -> heavy_f
Teen_Male_FullBody.gltf + scale (0.75, 0.72, 0.75) -> child [gender-neutral]
The fork scale factors and source mapping are unchanged from
blender_process_bodies.py this driver reuses the same table so the healthy six
are never touched. Output layout matches production: <out>/{body_type}/seg_*.glb.
"""
import sys
import os
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)
from blender_segment_body import segment_body
# (gltf_filename, body_type_key, scale, source_label) — forks only.
FORK_MANIFEST = [
("Regular_Male_FullBody.gltf", "thin_m", (0.82, 1.0, 0.88), "Regular_Male_FullBody.gltf"),
("Regular_Female_FullBody.gltf", "thin_f", (0.82, 1.0, 0.88), "Regular_Female_FullBody.gltf"),
("Regular_Male_FullBody.gltf", "heavy_m", (1.20, 1.08, 1.0), "Regular_Male_FullBody.gltf"),
("Regular_Female_FullBody.gltf", "heavy_f", (1.20, 1.08, 1.0), "Regular_Female_FullBody.gltf"),
("Teen_Male_FullBody.gltf", "child", (0.75, 0.72, 0.75), "Teen_Male_FullBody.gltf"),
]
FORK_README = """\
# Fork body type — auto-generated from mesh + armature scaling (T-1090)
This directory contains **{body_type}** body segments, generated by applying a
proportional scale to the source body ({source}) mesh AND its armature rest pose
together, then segmenting:
Scale: ({sx:.2f}, {sy:.2f}, {sz:.2f})
The scale is applied to the mesh vertices and the embedded armature rest pose
with the SAME affine (T-1090 fix). This keeps each segment internally consistent
with its own skeleton, which the shared-skeleton compositor
(character_visual.gd) requires a mesh-only scale detaches the head and
explodes the limbs on relocation.
Cross-sectional differentiation (thin = narrow, heavy = wide) survives the
composite; global height normalises to the shared skeleton (see T-1090 report /
Q-060 for the shared-skeleton scale-normalisation note).
Status: auto-generated (rebuilt T-1090), 18 segments incl. seg_hips
"""
def write_fork_readme(output_dir, body_type, source, scale):
sx, sy, sz = scale
with open(os.path.join(output_dir, "README.md"), "w") as f:
f.write(FORK_README.format(body_type=body_type, source=source, sx=sx, sy=sy, sz=sz))
if __name__ == "__main__":
argv = sys.argv
args = argv[argv.index("--") + 1:] if "--" in argv else []
if len(args) < 2:
print("Usage: -- <source_dir> <output_base_dir> [body_type ...]")
sys.exit(1)
source_dir = args[0]
output_base_dir = args[1]
only = set(args[2:]) # optional subset filter
manifest = [m for m in FORK_MANIFEST if not only or m[1] in only]
unique_sources = {gltf for (gltf, _, _, _) in manifest}
missing = [
os.path.join(source_dir, f)
for f in sorted(unique_sources)
if not os.path.exists(os.path.join(source_dir, f))
]
if missing:
print("\nERROR: Missing source GLTF files:")
for p in missing:
print(f" {p}")
sys.exit(1)
results = []
for (gltf_filename, body_type, scale, source_label) in manifest:
gltf_path = os.path.join(source_dir, gltf_filename)
output_dir = os.path.join(output_base_dir, body_type)
print(f"\n{'='*60}\n Fork: {body_type} scale={scale}\n Source: {gltf_filename}")
exported, skipped = segment_body(gltf_path, output_dir, scale)
write_fork_readme(output_dir, body_type, source_label, scale)
results.append((body_type, exported, skipped))
print(f"\n{'='*60}\n=== Fork rebuild complete ===")
for body_type, exported, skipped in results:
print(f" {body_type}: {len(exported)} exported, {len(skipped)} skipped")
@@ -0,0 +1,492 @@
"""
blender_segment_body.py
Usage:
tooling/blender --background --python tooling/blender_segment_body.py -- \
<input.gltf> <output_dir> [--scale sx sy sz]
Segments a Quaternius FullBody GLTF into 18 production GLBs:
seg_head, seg_neck, seg_torso, seg_torso_upper,
seg_arm_upper_l/r, seg_arm_lower_l/r, seg_hand_l/r,
seg_leg_upper_l/r, seg_leg_lower_l/r, seg_foot_l/r,
seg_eyes, seg_eyebrows
Each segment contains the vertices primarily weighted to its bone group
plus 1-ring boundary overlap for seam-free deformation.
Optional --scale sx sy sz applies a vertex-level scale to the mesh before
segmentation (for fork body types: thin, heavy, child).
Outputs: <output_dir>/seg_{name}.glb (18 files total)
Design: D-160 (18 segments per body type), D-164 (Source .blends as starting point)
"""
import sys
import os
import bpy
# --- Segment → bone vertex group mappings ---
# Each segment selects vertices with weight > 0 for ANY listed bone.
# 1-ring expansion adds boundary overlap for seam-free deformation (D-160).
SEGMENT_BONES = {
"seg_head": ["Head"],
"seg_neck": ["neck_01"],
"seg_torso": ["spine_01", "spine_02"],
"seg_torso_upper": ["spine_03", "clavicle_l", "clavicle_r"],
"seg_hips": ["pelvis"],
"seg_arm_upper_l": ["upperarm_l"],
"seg_arm_upper_r": ["upperarm_r"],
"seg_arm_lower_l": ["lowerarm_l"],
"seg_arm_lower_r": ["lowerarm_r"],
"seg_hand_l": [
"hand_l",
"index_01_l", "index_02_l", "index_03_l", "index_04_leaf_l",
"middle_01_l", "middle_02_l", "middle_03_l", "middle_04_leaf_l",
"pinky_01_l", "pinky_02_l", "pinky_03_l", "pinky_04_leaf_l",
"ring_01_l", "ring_02_l", "ring_03_l", "ring_04_leaf_l",
"thumb_01_l", "thumb_02_l", "thumb_03_l", "thumb_04_leaf_l",
],
"seg_hand_r": [
"hand_r",
"index_01_r", "index_02_r", "index_03_r", "index_04_leaf_r",
"middle_01_r", "middle_02_r", "middle_03_r", "middle_04_leaf_r",
"pinky_01_r", "pinky_02_r", "pinky_03_r", "pinky_04_leaf_r",
"ring_01_r", "ring_02_r", "ring_03_r", "ring_04_leaf_r",
"thumb_01_r", "thumb_02_r", "thumb_03_r", "thumb_04_leaf_r",
],
"seg_leg_upper_l": ["thigh_l"],
"seg_leg_upper_r": ["thigh_r"],
"seg_leg_lower_l": ["calf_l"],
"seg_leg_lower_r": ["calf_r"],
"seg_foot_l": ["foot_l", "ball_l", "ball_leaf_l"],
"seg_foot_r": ["foot_r", "ball_r", "ball_leaf_r"],
}
# Segments using a dedicated sub-object (not vertex-group-based segmentation)
OBJECT_SEGMENTS = {
"seg_eyes": "Eyes",
"seg_eyebrows": "Eyebrows",
}
# Ordered list for consistent output
SEGMENT_ORDER = [
"seg_head", "seg_neck",
"seg_torso_upper", "seg_torso", "seg_hips",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
"seg_hand_l", "seg_hand_r",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
"seg_foot_l", "seg_foot_r",
"seg_eyes", "seg_eyebrows",
]
WEIGHT_THRESHOLD = 0.01 # Minimum weight to count as "belonging" to a bone
EXPAND_RINGS = 0 # No overlap — clean segment boundaries for hiding/amputation
def find_objects(scene):
"""Identify the main body mesh, eyes, eyebrows, and armature."""
armature = None
body_mesh = None
special = {}
all_meshes = [o for o in scene.objects if o.type == 'MESH']
for obj in scene.objects:
if obj.type == 'ARMATURE':
armature = obj
elif obj.type == 'MESH':
name_upper = obj.name.upper()
if 'EYES' in name_upper and 'BROW' not in name_upper:
special['Eyes'] = obj
elif 'BROW' in name_upper:
special['Eyebrows'] = obj
# Body mesh = largest mesh not in special set
special_objs = set(special.values())
candidates = [o for o in all_meshes if o not in special_objs]
if candidates:
body_mesh = max(candidates, key=lambda o: len(o.data.vertices))
return body_mesh, special, armature
def apply_scale(mesh_obj, sx, sy, sz):
"""Scale mesh vertices in-place (mesh-local space)."""
if sx == 1.0 and sy == 1.0 and sz == 1.0:
return
print(f" Applying mesh scale: ({sx:.3f}, {sy:.3f}, {sz:.3f})")
for v in mesh_obj.data.vertices:
v.co.x *= sx
v.co.y *= sy
v.co.z *= sz
mesh_obj.data.update()
def apply_fork_scale(body_mesh, special_meshes, armature, sx, sy, sz):
"""
Scale a fork body (thin/heavy/child) so mesh AND armature stay CONSISTENT.
This is the load-bearing fix for fork body types (T-1090). The segment GLBs
are reparented onto a single SHARED skeleton at runtime (character_visual.gd
loads skeleton/armature.glb and drives all segments by bone name). A segment
composites coherently ONLY if its mesh matches its own embedded armature's
rest pose the shared skeleton then relocates the whole segment as a rigid
unit (this is why the pristine-rig teen body composites fine despite a very
different rig; see the T-1090 report).
The previous behaviour scaled mesh vertices ALONE, leaving the armature at
source scale: the head mesh dropped ~0.35 m below the Head bone, limbs flung
apart on relocation the "detached head / spider arms" misrender.
The scale must be BAKED by Blender via object transform_apply, NOT by poking
edit-bone head/tail directly: manual head/tail edits do not recompute bone
roll or honour connected-chain constraints, so long chains (armhandfingers,
neckhead) accumulate error and still explode. transform_apply rebuilds the
bone matrices correctly.
Method: de-parent meshes (keep transform) so mesh and armature are
independent objects sharing the world origin, give each the SAME object
scale, then apply. Identical affine about the same origin mesh verts and
bone rest move together; the Armature modifier + vertex groups re-derive a
consistent bind, which the glTF exporter bakes into the inverse-bind
matrices.
"""
if sx == 1.0 and sy == 1.0 and sz == 1.0:
return
print(f" Applying baked fork scale: ({sx:.3f}, {sy:.3f}, {sz:.3f})")
meshes = [body_mesh] + [m for m in special_meshes if m is not None]
if armature.mode != 'OBJECT':
bpy.context.view_layer.objects.active = armature
bpy.ops.object.mode_set(mode='OBJECT')
# De-parent meshes from the armature (keep world transform). The Armature
# MODIFIER and vertex groups are untouched — only the parenting relationship
# is cleared, so scaling each object about the origin is not double-applied.
bpy.ops.object.select_all(action='DESELECT')
for m in meshes:
if m.parent is armature:
m.select_set(True)
if bpy.context.selected_objects:
bpy.context.view_layer.objects.active = meshes[0]
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
# Scale armature + all meshes by the same object scale, then bake.
objs = [armature] + meshes
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
o.scale = (sx, sy, sz)
bpy.context.view_layer.objects.active = armature
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
def export_glb(objects, output_path):
"""Select the given objects and export as GLB."""
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
if objects:
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.export_scene.gltf(
filepath=output_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO',
export_texcoords=True,
export_normals=True,
export_skins=True,
export_materials='EXPORT',
)
def segment_by_bones(body_mesh, armature, bone_names, output_path):
"""
Extract a segment from body_mesh based on bone weights.
Uses face-based assignment: each face belongs to the segment whose bones
have the highest total weight across the face's vertices. No vertices are
deleted only faces that don't belong to this segment are removed.
This keeps all boundary vertices intact (shared with neighbors) so there
are no gaps, no holes, and no need for caps.
"""
import bmesh
# Duplicate the body mesh
bpy.ops.object.select_all(action='DESELECT')
body_mesh.select_set(True)
bpy.context.view_layer.objects.active = body_mesh
bpy.ops.object.duplicate(linked=False)
dup = bpy.context.active_object
if dup.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
# Get vertex group indices for target bones
vg_indices = set()
for name in bone_names:
vg = dup.vertex_groups.get(name)
if vg:
vg_indices.add(vg.index)
if not vg_indices:
print(f" WARNING: No vertex groups found for bones {bone_names}")
# Build BMesh
bm = bmesh.new()
bm.from_mesh(dup.data)
bm.verts.ensure_lookup_table()
bm.faces.ensure_lookup_table()
deform_layer = bm.verts.layers.deform.verify()
# Each face belongs to exactly ONE segment — the one whose bones have
# the highest total weight across the face's vertices. This prevents
# any face from appearing in two segments.
#
# We compute per-face the sum of weights for EVERY segment's bone set,
# then assign the face to the segment with the highest sum. We only
# keep faces assigned to THIS segment.
# Build a map of ALL segments' bone group indices for comparison.
# Exclude swappable variants (torso_upper) — they are subsets of their
# parent segment and should not compete in exclusive face assignment.
# torso_upper gets the same faces as torso, filtered to its bone subset.
VARIANT_SEGMENTS = set() # no variants — all segments are independent
all_segment_vg_indices = {}
for seg_name_key, seg_bones in SEGMENT_BONES.items():
if seg_name_key in VARIANT_SEGMENTS:
continue # skip variants in competition
seg_vg = set()
for bname in seg_bones:
vg = dup.vertex_groups.get(bname)
if vg:
seg_vg.add(vg.index)
all_segment_vg_indices[seg_name_key] = seg_vg
# For the current segment, use the key from SEGMENT_BONES that matches our bone_names
current_seg_key = None
for seg_name_key, seg_bones in SEGMENT_BONES.items():
if set(seg_bones) == set(bone_names):
current_seg_key = seg_name_key
break
if current_seg_key is None:
# Fallback: match by vg_indices
for seg_name_key, seg_vg in all_segment_vg_indices.items():
if seg_vg == vg_indices:
current_seg_key = seg_name_key
break
is_variant = current_seg_key in VARIANT_SEGMENTS
keep_faces = set()
if is_variant:
# Variant segments (e.g. torso_upper) are subsets of a parent.
# Keep only faces where the dominant bone (highest weight vertex)
# is exclusively in this variant's bone set, not the parent's
# extra bones. For torso_upper (spine_02, spine_03): keep faces
# where spine_02/spine_03 outweigh spine_01.
for face in bm.faces:
variant_w = 0.0
total_w = 0.0
for v in face.verts:
weights = v[deform_layer]
for idx, w in weights.items():
total_w += w
if idx in vg_indices:
variant_w += w
# Face belongs to variant if variant bones are dominant
if total_w > 0 and variant_w / total_w > 0.5:
keep_faces.add(face)
else:
# Primary segments: exclusive assignment via competition
for face in bm.faces:
best_seg = None
best_weight = -1.0
for seg_name_key, seg_vg in all_segment_vg_indices.items():
total_w = 0.0
for v in face.verts:
weights = v[deform_layer]
for idx in seg_vg:
if idx in weights:
total_w += weights[idx]
if total_w > best_weight:
best_weight = total_w
best_seg = seg_name_key
if best_seg == current_seg_key:
keep_faces.add(face)
print(f" Faces to keep: {len(keep_faces)} / {len(bm.faces)}")
# Delete faces NOT in keep_faces
faces_to_delete = [f for f in bm.faces if f not in keep_faces]
bmesh.ops.delete(bm, geom=faces_to_delete, context='FACES')
# Clean up: remove vertices that have no faces left
bm.verts.ensure_lookup_table()
orphan_verts = [v for v in bm.verts if not v.link_faces]
if orphan_verts:
bmesh.ops.delete(bm, geom=orphan_verts, context='VERTS')
# Write back
bm.to_mesh(dup.data)
bm.free()
dup.data.update()
remaining = len(dup.data.vertices)
print(f" Segment vertices after trim: {remaining}")
# Export segment + armature
bpy.ops.object.select_all(action='DESELECT')
dup.select_set(True)
if armature:
armature.select_set(True)
bpy.context.view_layer.objects.active = dup
export_glb([dup] + ([armature] if armature else []), output_path)
size = os.path.getsize(output_path)
print(f" Exported: {os.path.basename(output_path)} ({size:,} bytes)")
# Clean up duplicate
bpy.ops.object.select_all(action='DESELECT')
dup.select_set(True)
bpy.ops.object.delete()
def segment_special_object(special_obj, armature, output_path):
"""Export a special sub-object (Eyes / Eyebrows) as its own GLB segment."""
export_objs = [special_obj]
if armature:
export_objs.append(armature)
export_glb(export_objs, output_path)
size = os.path.getsize(output_path)
print(f" Exported: {os.path.basename(output_path)} ({size:,} bytes)")
def segment_body(gltf_path, output_dir, scale=(1.0, 1.0, 1.0)):
"""Main entry: load GLTF, apply optional scale, produce all 18 segment GLBs."""
print(f"\n Loading: {os.path.basename(gltf_path)}")
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=gltf_path)
body_mesh, special, armature = find_objects(bpy.context.scene)
if body_mesh is None:
print("ERROR: Could not find main body mesh")
sys.exit(1)
print(f" Body mesh: {body_mesh.name!r} ({len(body_mesh.data.vertices)} verts)")
print(f" Special: {list(special.keys())}")
print(f" Armature: {armature.name if armature else 'NONE'}")
# Remove utility objects (Icospheres, rig widgets, empties) that are not
# the body mesh, eyes, eyebrows, or armature. These can be children of the
# armature and would appear in all exported GLBs otherwise.
# Using bpy.data.objects.remove() (Python API) instead of ops — operators
# have context issues in headless Blender.
keepers = set(filter(None, [body_mesh, armature] + list(special.values())))
utility_objs = [
obj for obj in list(bpy.context.scene.objects)
if obj not in keepers
]
removed = 0
for obj in utility_objs:
# Clear parent relationship BEFORE removal so the armature stops
# treating it as a hierarchy child during GLTF export
if obj.parent is not None:
obj.parent = None
mesh_data = obj.data if obj.type == 'MESH' else None
bpy.data.objects.remove(obj, do_unlink=True)
if mesh_data and mesh_data.users == 0:
bpy.data.meshes.remove(mesh_data)
removed += 1
if removed:
remaining_names = [o.name for o in bpy.context.scene.objects]
print(f" Removed {removed} utility objects. Scene now: {remaining_names}")
# Apply optional scale transform (for fork body types). Mesh AND armature
# are scaled together and baked by Blender so the segment stays internally
# consistent when reparented onto the shared runtime skeleton (T-1090 fix —
# see apply_fork_scale).
sx, sy, sz = scale
if scale != (1.0, 1.0, 1.0):
apply_fork_scale(body_mesh, list(special.values()), armature, sx, sy, sz)
os.makedirs(output_dir, exist_ok=True)
# Strip animation data
for obj in bpy.context.scene.objects:
if obj.animation_data:
obj.animation_data_clear()
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
exported = []
skipped = []
for seg_name in SEGMENT_ORDER:
output_path = os.path.join(output_dir, seg_name + ".glb")
print(f"\n [{seg_name}]")
if seg_name in OBJECT_SEGMENTS:
obj_key = OBJECT_SEGMENTS[seg_name]
special_obj = special.get(obj_key)
if special_obj is None:
print(f" SKIP: no {obj_key!r} object found in scene")
skipped.append(seg_name)
continue
segment_special_object(special_obj, armature, output_path)
exported.append(seg_name)
elif seg_name in SEGMENT_BONES:
bone_names = SEGMENT_BONES[seg_name]
segment_by_bones(body_mesh, armature, bone_names, output_path)
exported.append(seg_name)
else:
print(f" SKIP: unknown segment {seg_name!r}")
skipped.append(seg_name)
return exported, skipped
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_segment_body.py -- <input.gltf> <output_dir> [--scale sx sy sz]")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide <input.gltf> and <output_dir>")
sys.exit(1)
gltf_path = args[0]
output_dir = args[1]
# Optional scale argument
scale = (1.0, 1.0, 1.0)
if "--scale" in args:
idx = args.index("--scale")
try:
scale = (float(args[idx + 1]), float(args[idx + 2]), float(args[idx + 3]))
except (IndexError, ValueError):
print("ERROR: --scale requires three floats: sx sy sz")
sys.exit(1)
exported, skipped = segment_body(gltf_path, output_dir, scale)
print(f"\n=== Segmentation complete: {len(exported)} segments, {len(skipped)} skipped ===")
for seg in exported:
path = os.path.join(output_dir, seg + ".glb")
print(f" {seg}.glb ({os.path.getsize(path):,} bytes)")
if skipped:
print(f" Skipped: {skipped}")
@@ -0,0 +1,459 @@
"""
blender_surface_deform_batch.py
Usage:
tooling/blender --background --python tooling/blender_surface_deform_batch.py -- \\
<reference_glb> <bodies_dir> <output_dir>
Fits a reference clothing GLB (authored on average_m) to all 11 body types via
Blender's Surface Deform modifier and writes fitted GLBs to the output directory.
Arguments:
reference_glb Path to the reference clothing mesh (modeled on average_m).
bodies_dir Directory containing one subdirectory per body type, each with
seg_*.glb segment files (e.g. client/assets/characters/bodies/).
output_dir Directory to write fitted variants. Created if it does not exist.
Output per run:
<output_dir>/thin_m.glb
<output_dir>/thin_f.glb
<output_dir>/average_m.glb <- direct copy of reference_glb
<output_dir>/average_f.glb
<output_dir>/muscular_m.glb
<output_dir>/muscular_f.glb
<output_dir>/teen_m.glb
<output_dir>/teen_f.glb
<output_dir>/heavy_m.glb
<output_dir>/heavy_f.glb
<output_dir>/child.glb
Headless Surface Deform reliability:
MEDIUM. The modifier bind operator requires an active object context. This
script applies a context override (bpy.context.temp_override) to satisfy
the operator. If the bind fails (is_bound == False after the attempt), the
script falls back to a Shrinkwrap ON_SURFACE projection and logs a WARNING.
The Shrinkwrap fallback produces acceptable results for most garments but
may cause pinching at extremities on extreme body types (heavy_m, heavy_f).
Visually verify all output variants at gameplay zoom before treating this
pipeline as production-ready. See VERDICT.md open question: Surface Deform
visual quality at extreme body types.
UV layout:
Surface Deform only moves vertex positions -- UVs are not altered.
Shrinkwrap fallback also preserves UV layout.
Decisions: D-162 (clothing pre-baked per body type via Surface Deform)
"""
import sys
import os
import json
import shutil
import bpy
# -------------------------------------------------------------------------
# Constants
# -------------------------------------------------------------------------
BODY_TYPES = [
"thin_m",
"thin_f",
"average_m", # reference copy -- no deform
"average_f",
"muscular_m",
"muscular_f",
"teen_m",
"teen_f",
"heavy_m",
"heavy_f",
"child",
]
REFERENCE_BODY = "average_m"
# Body segments to import for the deform surface.
# Eyes and eyebrows are excluded -- they are tiny facial sub-objects that
# do not affect clothing deformation.
DEFORM_SEGMENTS = [
"seg_head",
"seg_neck",
"seg_torso",
"seg_torso_upper",
"seg_arm_upper_l",
"seg_arm_upper_r",
"seg_arm_lower_l",
"seg_arm_lower_r",
"seg_hand_l",
"seg_hand_r",
"seg_leg_upper_l",
"seg_leg_upper_r",
"seg_leg_lower_l",
"seg_leg_lower_r",
"seg_foot_l",
"seg_foot_r",
]
SHRINKWRAP_OFFSET = 0.002 # Small offset to avoid z-fighting (metres)
# -------------------------------------------------------------------------
# Scene helpers
# -------------------------------------------------------------------------
def clear_scene():
"""Remove all objects from the current scene."""
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Also purge orphan mesh/material data to avoid memory bloat in batch runs
bpy.ops.outliner.orphans_purge(do_recursive=True)
def import_glb(path):
"""Import a GLB file. Returns newly created objects."""
before = set(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=path)
return [o for o in bpy.context.scene.objects if o not in before]
def get_meshes(objects):
"""Filter a list of objects to mesh-type only."""
return [o for o in objects if o.type == 'MESH']
def export_glb(obj, output_path):
"""
Export a single mesh object as GLB.
Preserves UVs, normals, and materials. No armature or animations.
"""
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.export_scene.gltf(
filepath=output_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO',
export_texcoords=True,
export_normals=True,
export_skins=False,
export_materials='EXPORT',
)
# -------------------------------------------------------------------------
# Body surface construction
# -------------------------------------------------------------------------
def build_body_surface(bodies_dir, body_type):
"""
Import and join body segments for the given body type into a single
mesh object suitable for Surface Deform binding.
Returns the joined mesh object, or None if the body type directory is
missing or contains no usable segments.
"""
body_dir = os.path.join(bodies_dir, body_type)
if not os.path.isdir(body_dir):
print(f" WARNING: Body type directory not found: {body_dir}")
return None
imported = []
missing = []
for seg_name in DEFORM_SEGMENTS:
seg_path = os.path.join(body_dir, f"{seg_name}.glb")
if not os.path.isfile(seg_path):
missing.append(seg_name)
continue
objs = import_glb(seg_path)
imported.extend(get_meshes(objs))
if missing:
print(f" NOTE: {len(missing)} segments missing for {body_type} "
f"(non-fatal): {', '.join(missing)}")
if not imported:
print(f" ERROR: No segment meshes imported for {body_type}")
return None
print(f" Imported {len(imported)} segments for {body_type}")
# Select all imported meshes and join them into one
bpy.ops.object.select_all(action='DESELECT')
for obj in imported:
obj.select_set(True)
bpy.context.view_layer.objects.active = imported[0]
bpy.ops.object.join()
body_surface = bpy.context.active_object
body_surface.name = f"body_surface_{body_type}"
return body_surface
# -------------------------------------------------------------------------
# Surface Deform fitting
# -------------------------------------------------------------------------
def try_surface_deform_bind(clothing_obj, body_surface, mod_name):
"""
Attempt to bind the Surface Deform modifier using a context override.
Returns True if bind succeeded, False otherwise.
Headless workaround:
bpy.ops.object.surfacedeform_bind() requires an active viewport window
context. In --background mode, we construct a temporary override that
satisfies the operator's active_object requirement. Blender 3.6+ supports
this via bpy.context.temp_override().
"""
clothing_obj.select_set(True)
bpy.context.view_layer.objects.active = clothing_obj
try:
with bpy.context.temp_override(
active_object=clothing_obj,
object=clothing_obj,
selected_objects=[clothing_obj],
):
result = bpy.ops.object.surfacedeform_bind(modifier=mod_name)
mod = clothing_obj.modifiers.get(mod_name)
if mod and mod.is_bound:
print(" Surface Deform bind: SUCCESS")
return True
else:
print(f" Surface Deform bind: operator returned {result}, "
f"is_bound=False — bind did not complete")
return False
except Exception as exc:
print(f" Surface Deform bind: EXCEPTION — {exc}")
return False
def apply_shrinkwrap_fallback(clothing_obj, body_surface):
"""
Fallback fitting via Shrinkwrap ON_SURFACE projection.
Less accurate than Surface Deform (no barycentric interpolation) but
reliable in headless mode. May cause pinching at extremities.
UV layout is preserved.
"""
print(" Using Shrinkwrap fallback (Surface Deform bind failed)")
sw = clothing_obj.modifiers.new("ShrinkwrapFit", 'SHRINKWRAP')
sw.target = body_surface
sw.wrap_method = 'NEAREST_SURFACEPOINT'
sw.wrap_mode = 'ON_SURFACE'
sw.offset = SHRINKWRAP_OFFSET
bpy.ops.object.select_all(action='DESELECT')
clothing_obj.select_set(True)
bpy.context.view_layer.objects.active = clothing_obj
bpy.ops.object.modifier_apply(modifier=sw.name)
def fit_clothing_to_body(clothing_obj, body_surface):
"""
Fit clothing_obj to body_surface using Surface Deform, with Shrinkwrap
fallback. Returns the method used ('surface_deform' or 'shrinkwrap').
"""
# Add Surface Deform modifier
mod = clothing_obj.modifiers.new("SurfaceDeformFit", 'SURFACE_DEFORM')
mod.target = body_surface
mod_name = mod.name
bound = try_surface_deform_bind(clothing_obj, body_surface, mod_name)
if bound:
# Apply the Surface Deform modifier
bpy.ops.object.select_all(action='DESELECT')
clothing_obj.select_set(True)
bpy.context.view_layer.objects.active = clothing_obj
bpy.ops.object.modifier_apply(modifier=mod_name)
return 'surface_deform'
else:
# Remove the failed Surface Deform modifier before falling back
clothing_obj.modifiers.remove(mod)
apply_shrinkwrap_fallback(clothing_obj, body_surface)
return 'shrinkwrap'
# -------------------------------------------------------------------------
# Main per-body-type processing
# -------------------------------------------------------------------------
def process_body_type(body_type, reference_glb, bodies_dir, output_dir):
"""
Process a single body type. Returns a result dict with status info.
"""
output_path = os.path.join(output_dir, f"{body_type}.glb")
print(f"\n{'='*60}")
print(f" Body type: {body_type}")
# average_m is a direct copy -- no deform required
if body_type == REFERENCE_BODY:
print(" Reference body type -- copying reference directly")
shutil.copy2(reference_glb, output_path)
return {"body_type": body_type, "status": "ok", "method": "copy"}
clear_scene()
# --- Import reference clothing ---
print(f" Importing reference clothing: {os.path.basename(reference_glb)}")
clothing_objs = import_glb(reference_glb)
clothing_meshes = get_meshes(clothing_objs)
if not clothing_meshes:
return {"body_type": body_type, "status": "error",
"error": "No mesh found in reference GLB"}
# If reference GLB contains multiple meshes, join them
if len(clothing_meshes) > 1:
bpy.ops.object.select_all(action='DESELECT')
for m in clothing_meshes:
m.select_set(True)
bpy.context.view_layer.objects.active = clothing_meshes[0]
bpy.ops.object.join()
clothing_obj = bpy.context.active_object
clothing_obj.name = "clothing_ref"
print(f" Clothing mesh: {len(clothing_obj.data.vertices)} vertices")
# --- Build body surface ---
print(" Building body surface from segments...")
body_surface = build_body_surface(bodies_dir, body_type)
if body_surface is None:
return {"body_type": body_type, "status": "error",
"error": "Could not construct body surface"}
print(f" Body surface: {len(body_surface.data.vertices)} vertices")
# --- Fit clothing to body ---
method = fit_clothing_to_body(clothing_obj, body_surface)
# --- Export fitted clothing ---
# Delete body surface first so only clothing exports
bpy.data.objects.remove(body_surface, do_unlink=True)
# Re-get clothing obj (still active after modifier apply)
clothing_obj = bpy.context.active_object
print(f" Exporting to {os.path.basename(output_path)}")
export_glb(clothing_obj, output_path)
return {"body_type": body_type, "status": "ok", "method": method}
# -------------------------------------------------------------------------
# Entry point
# -------------------------------------------------------------------------
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python "
"tooling/blender_surface_deform_batch.py -- "
"<reference_glb> <bodies_dir> <output_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 3:
print("ERROR: Provide <reference_glb>, <bodies_dir>, and <output_dir>")
sys.exit(1)
reference_glb = args[0]
bodies_dir = args[1]
output_dir = args[2]
# Preflight checks
if not os.path.isfile(reference_glb):
print(f"ERROR: Reference GLB not found: {reference_glb}")
sys.exit(1)
if not os.path.isdir(bodies_dir):
print(f"ERROR: Bodies directory not found: {bodies_dir}")
sys.exit(1)
os.makedirs(output_dir, exist_ok=True)
# Validate that body type directories are present
missing_types = [
bt for bt in BODY_TYPES
if bt != REFERENCE_BODY and not os.path.isdir(os.path.join(bodies_dir, bt))
]
if missing_types:
print(f"\nWARNING: Missing body type directories (will skip): "
f"{', '.join(missing_types)}")
print("\nSurface Deform Batch Pipeline")
print(f" Reference: {reference_glb}")
print(f" Bodies: {bodies_dir}")
print(f" Output: {output_dir}")
print(f" Types: {len(BODY_TYPES)}")
results = []
for body_type in BODY_TYPES:
if body_type != REFERENCE_BODY and \
not os.path.isdir(os.path.join(bodies_dir, body_type)):
results.append({"body_type": body_type, "status": "skipped",
"error": "body type directory missing"})
continue
result = process_body_type(body_type, reference_glb, bodies_dir, output_dir)
results.append(result)
# Summary
print(f"\n{'='*60}")
print("=== Surface Deform batch complete ===")
ok = [r for r in results if r["status"] == "ok"]
errors = [r for r in results if r["status"] == "error"]
skipped = [r for r in results if r["status"] == "skipped"]
sd_count = len([r for r in ok if r.get("method") == "surface_deform"])
sw_count = len([r for r in ok if r.get("method") == "shrinkwrap"])
copy_count = len([r for r in ok if r.get("method") == "copy"])
for r in results:
status = r["status"].upper()
method = r.get("method", "")
error = r.get("error", "")
if method:
print(f" {r['body_type']:15s} {status:8s} [{method}]")
elif error:
print(f" {r['body_type']:15s} {status:8s} {error}")
else:
print(f" {r['body_type']:15s} {status:8s}")
print(f"\n OK: {len(ok)} "
f"(surface_deform={sd_count}, shrinkwrap={sw_count}, copy={copy_count})")
if errors:
print(f" ERRORS: {len(errors)}")
for r in errors:
print(f" {r['body_type']}: {r.get('error', '?')}")
if skipped:
print(f" SKIPPED: {len(skipped)}")
if sw_count > 0:
print(f"\n WARNING: {sw_count} body type(s) used Shrinkwrap fallback.")
print(" Visually verify these variants at gameplay zoom — Shrinkwrap")
print(" may produce pinching at extremities on extreme body types.")
# Write pipeline_log.json for provenance tracking
log_path = os.path.join(output_dir, "pipeline_log.json")
log_data = {
"reference": os.path.basename(reference_glb),
"bodies_dir": bodies_dir,
"variants": {
r["body_type"]: {
"status": r["status"],
"method": r.get("method", None),
"error": r.get("error", None),
}
for r in results
},
}
with open(log_path, 'w') as f:
json.dump(log_data, f, indent=2)
print(f"\n Pipeline log written to {log_path}")
if errors:
sys.exit(1)