feat(assets): wardrobe wave 2 — footwear, sportswear, swimwear, sweater, cargos, thrds parka, slides (T-1089)
Twelve garments, per-body on all 11 bodies, chromakey-gated (worst clips: parka 3px, boots 54, sweater/tank 83, sneakers 100 final-geometry, slides 123, swim trunks 132, cargo 138 — all under the 150px gate), previewed: tank top, sweater (crew-neck via per-body ring-valley probe — first draft read mock-neck, fixed by measured rim circularization), track jacket (recolorable sleeve-stripe region), joggers (side-stripe region), cargo pants, swim trunks, one-piece swimsuit, sneakers (prism-sole), formal shoes, ankle boots (calf shaft), slides (open strap + sole), and the hip-length thrds parka — the first canon-branded garment (Braemar cold-weather cooperative), quilted, logo-capable. Tops now hem into real hip geometry (the natural seg_torso bottom is a 9-14cm tooth ring — the sweater established the hem-into-hips practice). Manifest merged by the lead: 24 clothing entries with region/default-tint metadata. Full modern catalogue: 21 garments across tops/bottoms/feet/ full-body x casual/formal/sport/swim/outerwear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,740 @@
|
||||
"""
|
||||
blender_author_boots.py (T-1089 wave 2, boots_modern + ankle-boot family)
|
||||
|
||||
Authors ANKLE BOOTS as per-body offset shells: both feet PLUS the lower calf
|
||||
(seg_leg_lower cut to a boot shaft just above the ankle), with a chunky sole
|
||||
extension and painted eyelets/laces up the shaft. Reuses
|
||||
blender_author_offset_shell.py (via blender_author_denim_pants.py, imported as
|
||||
a module) for the shared machinery — scene build, join, offset, solidify,
|
||||
albedo material, GLB export — plus the two denim REQUIRED PRACTICES for
|
||||
bottoms/footwear:
|
||||
|
||||
* boundary WELD of coincident segment-seam rings (denim.weld_boundaries):
|
||||
the ankle joins (foot<->leg_lower) duplicate a coincident vert band per
|
||||
segment; offsetting un-welded rings along diverging normals opens cracks.
|
||||
* open-rim FLATTENING: the shaft cut leaves a jagged "teeth" ring on each
|
||||
calf; both rims are pulled down onto one clean shared plane (the deeper
|
||||
valley of the two, so the boots match) before offsetting.
|
||||
|
||||
What this companion adds, as reusable parameters (not hacks):
|
||||
|
||||
* --shaft-frac: boot shaft height as a fraction of the ankle->knee calf
|
||||
span (0.35 cut => rim lands on the lower calf after rim flattening;
|
||||
larger values give combat/riding variants).
|
||||
* TOE-BOX MERGE: the skin mesh has individual toes; a boot must not. The
|
||||
foot region is Laplacian-smoothed pre-offset (mild on the whole foot,
|
||||
aggressive forward of the ball joint) so the toes fuse into one rounded
|
||||
leather toe box and anatomical detail (ankle knobs, heel tendon) reads
|
||||
as boot, not foot. Weights/UVs ride along untouched.
|
||||
* SKIN CONTAINMENT CLAMP: smoothing can pull the shell inside the skin it
|
||||
no longer follows (melted toe box vs real toes). A BVH of the ORIGINAL
|
||||
welded skin is kept, and after the offset every foot-region vert whose
|
||||
signed distance to the skin is below a minimum clearance is pushed back
|
||||
out along the skin normal — standoff guaranteed by construction, which
|
||||
is what the chromakey QA gates on (the runtime does not hide segments).
|
||||
* FOOT WEIGHT RE-BIND: smoothing + clamping RELOCATE shell verts but leave
|
||||
them carrying their ORIGIN vertex's bone weights, so material that ends
|
||||
up hovering over toe N flexes with the bone of toe M — under ball-joint
|
||||
flexion (Walk push-off, crouch) the shell diverges from the skin beneath
|
||||
it and the toes poke through (QA/preview evidence, wave 2 run 1/2).
|
||||
After shaping, every foot-region vert re-copies its vertex-group weights
|
||||
from the nearest vert of the original welded skin, so the boot flexes
|
||||
exactly with the anatomy each patch of leather actually covers.
|
||||
* SOLE geometry as an offset-shell param extension (--sole-drop): the
|
||||
under-foot surface created by the outward offset is flattened onto a slab
|
||||
plane sole_drop below the skin sole (solidify adds its thickness back,
|
||||
so the shipped slab bottom sits exactly sole_drop under the skin), and a
|
||||
feathered radial LIP (~5 mm) bulges the sole band outward for the chunky
|
||||
work-boot silhouette.
|
||||
* --shaft-flare: extra feathered radial stand-off toward the shaft rim.
|
||||
Serves the same crouch-fold purpose as the denim waist flare AND buys
|
||||
clearance so full-length pant hems (jeans/formal, 12 mm standoff) tuck
|
||||
INSIDE the boot shaft instead of z-fighting with it.
|
||||
* TEXEL-level feature painting (denim technique, boot field): one analytic
|
||||
field evaluation drives BOTH the painted albedo and the region mask so
|
||||
they always agree — lace bars + eyelet dots up the front of the shaft,
|
||||
welt stitching above the sole, padded collar shading at the rim.
|
||||
Regions (spec): sole -> R, upper + shaft -> G, laces + eyelets -> B.
|
||||
* a parked logo_uv TEXCOORD_1 layer (boots are not logo-capable, but
|
||||
toon_garment.gdshader samples UV2 unconditionally).
|
||||
|
||||
All cut/mask/paint parameters derive PER BODY from that body's own calf bone
|
||||
landmarks and measured skin-sole plane, scaled by the calf-span ratio against
|
||||
average_m (clamped so the child keeps believable, not clown, proportions) —
|
||||
the same proportional-ratio philosophy as base.derive_thresholds. Per-body
|
||||
mode only (offset shells author per body, Q-060).
|
||||
|
||||
Style pin (T-1089): modern only; identity carried by the texture. The albedo
|
||||
is painted at mid luma so toon_garment.gdshader's luma-preserving recolor
|
||||
stays faithful; the brown/black work-street default lives in the manifest
|
||||
default_tints (sole near-black, upper work brown, laces dark).
|
||||
|
||||
Usage (boots_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_boots.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/boots_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.013] [--shaft-frac 0.30] \
|
||||
[--shaft-flare 0.008] [--sole-drop 0.020] [--base-rgb r,g,b] [--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the denim companion as a library (which itself imports the base
|
||||
# offset-shell module). Boot reuse: base machinery + denim's weld/parked-UV2.
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"denim_pants_lib", os.path.join(_HERE, "blender_author_denim_pants.py"))
|
||||
denim = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(denim)
|
||||
|
||||
base = denim.base
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_foot_l", "seg_foot_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
]
|
||||
|
||||
SHAFT_FRAC = 0.35 # boot shaft = this fraction of the ankle->knee span
|
||||
BOOT_OFFSET_M = 0.013 # standoff along normals (leather sits off the skin)
|
||||
SHAFT_FLARE_M = 0.008 # extra radial stand-off at the shaft rim (feathered)
|
||||
SOLE_DROP_M = 0.020 # chunky sole slab depth below the skin sole (spec)
|
||||
SOLE_LIP_M = 0.008 # radial sole bulge (chunky work-boot lip)
|
||||
SOLE_LIP_TOP_M = 0.020 # lip feather reaches this far above the skin sole
|
||||
TEX_SIZE = 1024 # albedo + mask resolution (painted laces need >512)
|
||||
NOISE_SEED = 3089
|
||||
|
||||
# Toe-box merge (pre-offset Laplacian smoothing).
|
||||
FOOT_SMOOTH_ITERS = 4 # mild pass over the whole foot (below the ankle)
|
||||
FOOT_SMOOTH_FACTOR = 0.5
|
||||
TOE_SMOOTH_ITERS = 10 # aggressive pass forward of the ball joint
|
||||
TOE_SMOOTH_FACTOR = 1.0
|
||||
TOE_ZONE_BALL_FRAC = 0.80 # toe zone starts at this fraction of the ball-y
|
||||
|
||||
# Skin containment clamp (post-offset).
|
||||
CLAMP_CLEAR_FRAC = 1.0 # minimum clearance as a fraction of the offset.
|
||||
# At 1.0 the clamp restores the full standoff: the
|
||||
# clamp moves VERTS, and a face spanning two clamped
|
||||
# verts can still dip toward the high-frequency skin
|
||||
# toes between them (QA/preview evidence: toe tips
|
||||
# poked the melted toe box at 0.75). Higher values
|
||||
# (1.15 tried) buy little once the foot weight
|
||||
# re-bind aligns flexion, and read as a lumpy box.
|
||||
CLAMP_ITERS = 3
|
||||
|
||||
# Painted-detail metrics (metres on average_m; scaled by the calf-span ratio).
|
||||
LACE_PITCH_M = 0.017 # vertical distance between lace bars
|
||||
LACE_BAR_HW_M = 0.0026 # half-height of a painted lace bar
|
||||
LACE_PANEL_HW_M = 0.015 # half arc-width of the lace panel
|
||||
EYELET_R_M = 0.0032 # eyelet dot radius (at the panel edges)
|
||||
LACE_LO_FRAC = 0.0 # panel starts at the ankle joint (shaft only)
|
||||
COLLAR_H_M = 0.014 # padded collar band at the shaft rim (albedo shade)
|
||||
SOLE_TOP_M = 0.014 # R region reaches this far above the skin sole
|
||||
WELT_OFF_M = 0.0035 # welt stitch line offset above the sole top
|
||||
SEAM_HW_M = 0.0018 # painted stitch line half-width
|
||||
TOECAP_STITCH = True # painted toe-cap stitch line at the ball joint
|
||||
|
||||
# Albedo tones (sRGB floats). Mid-luma leather so the toon_garment luma
|
||||
# recolor keeps its dynamic range; hue defaults come from manifest tints.
|
||||
LEATHER_RGB = (0.58, 0.44, 0.32) # light tan work leather
|
||||
SOLE_RGB = (0.30, 0.29, 0.28) # darker rubber (low luma -> reads black)
|
||||
LACE_RGB = (0.24, 0.21, 0.19) # dark laces
|
||||
EYELET_RGB = (0.85, 0.78, 0.60) # bright metal eyelet glint
|
||||
WELT_RGB = (0.78, 0.62, 0.40) # welt stitching thread
|
||||
PANEL_SHADE = 0.90 # lace-panel background darkening
|
||||
COLLAR_SHADE = 0.86 # padded collar darkening
|
||||
ALBEDO_NOISE = 0.020 # +/- grain jitter
|
||||
PLAIN = False # --plain: skip laces/eyelets/welt paint
|
||||
|
||||
# Reference proportions (average_m) the scale ratio is anchored to.
|
||||
_REF_CALF_SPAN = 0.4559 # calf head z (0.5424) - calf tail z (0.0865)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class BootLandmarks:
|
||||
"""Cut/mask/paint parameters from one body's calf bones + skin-sole plane."""
|
||||
|
||||
def __init__(self, armature, skin_min_z):
|
||||
bones = armature.data.bones
|
||||
calf_l = bones.get("calf_l")
|
||||
calf_r = bones.get("calf_r")
|
||||
if calf_l is None or calf_r is None:
|
||||
raise RuntimeError("calf_l/calf_r missing — not the 65-bone rig?")
|
||||
self.ankle_z = (calf_l.tail_local.z + calf_r.tail_local.z) / 2.0
|
||||
self.knee_z = (calf_l.head_local.z + calf_r.head_local.z) / 2.0
|
||||
self.skin_min_z = skin_min_z
|
||||
self.calf_span = self.knee_z - self.ankle_z
|
||||
# Detail scale: proportional to the calf span, clamped so small bodies
|
||||
# keep believable (not clown, not doll) sole/lace metrics.
|
||||
self.s = min(max(self.calf_span / _REF_CALF_SPAN, 0.55), 1.15)
|
||||
# Per-z leg axis control points (ankle -> knee) for the +x leg;
|
||||
# the right leg mirrors via the x sign. np.interp clamps outside.
|
||||
self.leg_z_pts = np.array([calf_l.tail_local.z, calf_l.head_local.z])
|
||||
self.leg_x_pts = np.array(
|
||||
[abs(calf_l.tail_local.x), abs(calf_l.head_local.x)])
|
||||
self.leg_y_pts = np.array([calf_l.tail_local.y, calf_l.head_local.y])
|
||||
self.shaft_top_z = self.ankle_z + SHAFT_FRAC * self.calf_span
|
||||
self.rim_z = self.shaft_top_z # finalized after rim flattening
|
||||
# Ball joint (toe-box hinge): forward distance on the front axis.
|
||||
ball = bones.get("ball_l")
|
||||
self.ball_front = (ball.head_local.y * FRONT_Y_SIGN
|
||||
if ball is not None else None)
|
||||
|
||||
def finalize(self, rim_z):
|
||||
self.rim_z = rim_z
|
||||
self.sole_top_z = self.skin_min_z + SOLE_TOP_M * self.s
|
||||
self.lace_lo = self.ankle_z - LACE_LO_FRAC * (self.ankle_z - self.skin_min_z)
|
||||
self.lace_hi = self.rim_z - 0.7 * COLLAR_H_M * self.s
|
||||
log(f"landmarks: ankle={self.ankle_z:.3f} knee={self.knee_z:.3f} "
|
||||
f"sole={self.skin_min_z:.3f} rim={self.rim_z:.3f} s={self.s:.2f} "
|
||||
f"sole_top={self.sole_top_z:.3f} "
|
||||
f"laces z=[{self.lace_lo:.3f},{self.lace_hi:.3f}]")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: shaft cut + rim flatten + flare + sole
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def build_skin_bvh(shell):
|
||||
"""BVH of the welded skin BEFORE any shaping — the containment reference.
|
||||
|
||||
Returns (bvh, bm); the bmesh must stay alive as long as the BVH is used.
|
||||
"""
|
||||
from mathutils.bvhtree import BVHTree
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
return BVHTree.FromBMesh(bm), bm
|
||||
|
||||
|
||||
def containment_clamp(shell, skin_bvh, min_clear, z_max):
|
||||
"""Push shell verts back outside the original skin (post-offset).
|
||||
|
||||
The toe-box merge can leave the smoothed shell INSIDE the real skin toes;
|
||||
any vert (below z_max) whose signed distance to the skin surface is less
|
||||
than min_clear is moved to min_clear along the skin normal. Iterated,
|
||||
because in concave spots (toe crevices) the first push can land near
|
||||
another skin face."""
|
||||
me = shell.data
|
||||
total = 0
|
||||
for it in range(CLAMP_ITERS):
|
||||
moved = 0
|
||||
for v in me.vertices:
|
||||
if v.co.z > z_max:
|
||||
continue
|
||||
loc, nrm, _idx, _dist = skin_bvh.find_nearest(v.co)
|
||||
if loc is None:
|
||||
continue
|
||||
if (v.co - loc).dot(nrm) < min_clear:
|
||||
v.co = loc + nrm * min_clear
|
||||
moved += 1
|
||||
total += moved
|
||||
if moved == 0:
|
||||
break
|
||||
me.update()
|
||||
log(f"containment clamp: {total} vert pushes "
|
||||
f"(min clearance {min_clear * 1000:.1f} mm)")
|
||||
|
||||
|
||||
def transfer_foot_weights(shell, skin_bm, skin_bvh, z_max):
|
||||
"""Re-copy foot-region vertex weights from the nearest ORIGINAL skin vert.
|
||||
|
||||
Smoothing + clamping relocate shell verts while they keep their origin
|
||||
vert's weights, so leather hovering over toe N can flex with toe M's bone
|
||||
— under ball flexion the shell then diverges from the skin it covers and
|
||||
the toes poke through. Copying each relocated vert's weights from the
|
||||
nearest vert of the original welded skin re-aligns material to anatomy.
|
||||
Group indices match by construction (same object, groups untouched).
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
dl = bm.verts.layers.deform.verify()
|
||||
sdl = skin_bm.verts.layers.deform.verify()
|
||||
skin_bm.faces.ensure_lookup_table()
|
||||
rebound = 0
|
||||
for v in bm.verts:
|
||||
if v.co.z > z_max:
|
||||
continue
|
||||
loc, _nrm, fidx, _dist = skin_bvh.find_nearest(v.co)
|
||||
if loc is None:
|
||||
continue
|
||||
face = skin_bm.faces[fidx]
|
||||
sv = min(face.verts, key=lambda fv: (fv.co - v.co).length_squared)
|
||||
src = sv[sdl]
|
||||
dst = v[dl]
|
||||
dst.clear()
|
||||
for g, w in src.items():
|
||||
dst[g] = w
|
||||
rebound += 1
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"foot weight re-bind: {rebound} verts re-copied from nearest skin")
|
||||
|
||||
|
||||
def toe_merge(shell, lm):
|
||||
"""Fuse the skin toes into one rounded boot toe box (pre-offset).
|
||||
|
||||
Two Laplacian passes: a mild one over the whole foot (below the ankle)
|
||||
rounds anatomical detail into leather, an aggressive one forward of the
|
||||
ball joint melts the individual toes together. Vertex weights and UVs are
|
||||
untouched, so skinning and texel painting are unaffected; the outward
|
||||
offset afterwards restores the lost girth."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
foot = [v for v in bm.verts if v.co.z < lm.ankle_z]
|
||||
for _ in range(FOOT_SMOOTH_ITERS):
|
||||
bmesh.ops.smooth_vert(bm, verts=foot, factor=FOOT_SMOOTH_FACTOR,
|
||||
use_axis_x=True, use_axis_y=True, use_axis_z=True)
|
||||
n_toe = 0
|
||||
if lm.ball_front is not None:
|
||||
toe_y = lm.ball_front * TOE_ZONE_BALL_FRAC
|
||||
toes = [v for v in foot if v.co.y * FRONT_Y_SIGN > toe_y]
|
||||
n_toe = len(toes)
|
||||
for _ in range(TOE_SMOOTH_ITERS):
|
||||
bmesh.ops.smooth_vert(bm, verts=toes, factor=TOE_SMOOTH_FACTOR,
|
||||
use_axis_x=True, use_axis_y=True,
|
||||
use_axis_z=True)
|
||||
else:
|
||||
log("WARNING: ball_l bone missing — toe box keeps skin toes")
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"toe merge: smoothed {len(foot)} foot verts "
|
||||
f"({FOOT_SMOOTH_ITERS}x{FOOT_SMOOTH_FACTOR}), "
|
||||
f"{n_toe} toe verts ({TOE_SMOOTH_ITERS}x{TOE_SMOOTH_FACTOR})")
|
||||
|
||||
|
||||
def shaft_cut(shell, lm):
|
||||
"""Trim the calves above the boot-shaft plane (bone-derived)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts if v.co.z > lm.shaft_top_z]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"shaft cut at z={lm.shaft_top_z:.3f}: removed {len(doomed)} verts")
|
||||
|
||||
|
||||
def flatten_shaft_rims(shell, lm):
|
||||
"""Pull both shaft-rim teeth rings onto ONE shared clean plane (pre-offset).
|
||||
|
||||
The plane cut follows mesh topology, so each rim is a jagged ring of
|
||||
teeth. Both rings flatten DOWN to the common valley (deepest notch of
|
||||
either ring) — a clean straight rim, identical height on both boots, no
|
||||
invented coverage. Any stray open-boundary verts below the shaft (weld
|
||||
leftovers) are left alone and reported. Returns the rim plane z.
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
low_thresh = lm.ankle_z + 0.25 * (lm.shaft_top_z - lm.ankle_z)
|
||||
rims = [i for i in boundary if bm.verts[i].co.z > low_thresh]
|
||||
stray = len(boundary) - len(rims)
|
||||
if not rims:
|
||||
bm.free()
|
||||
raise RuntimeError("no shaft rim boundary verts found")
|
||||
valley = min(bm.verts[i].co.z for i in rims)
|
||||
teeth = max(bm.verts[i].co.z for i in rims) - valley
|
||||
for i in rims:
|
||||
bm.verts[i].co.z = valley
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened shaft rims: {len(rims)} verts, teeth {teeth:.3f} m "
|
||||
f"-> z={valley:.3f}"
|
||||
+ (f" (WARNING: {stray} stray low boundary verts left)" if stray else ""))
|
||||
return valley
|
||||
|
||||
|
||||
def shaft_flare(shell, lm, rim_z, flare):
|
||||
"""Extra RADIAL stand-off toward the shaft rim, feathered from the ankle
|
||||
(post-offset, pre-solidify). Buys pant-hem clearance + chunky read."""
|
||||
if flare <= 0.0:
|
||||
return
|
||||
span = max(rim_z - lm.ankle_z, 1e-6)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
n = 0
|
||||
for v in bm.verts:
|
||||
if v.co.z > lm.ankle_z:
|
||||
t = min((v.co.z - lm.ankle_z) / span, 1.0)
|
||||
nx, ny = v.normal.x, v.normal.y
|
||||
mag = (nx * nx + ny * ny) ** 0.5
|
||||
if mag > 1e-6:
|
||||
v.co.x += flare * t * nx / mag
|
||||
v.co.y += flare * t * ny / mag
|
||||
n += 1
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"shaft flare: {n} verts, +{flare * 1000:.1f} mm radial at rim")
|
||||
|
||||
|
||||
def sole_shape(shell, lm, offset, sole_drop):
|
||||
"""Chunky sole (post-offset, pre-solidify): feathered radial LIP around
|
||||
the sole band, then the under-foot offset surface flattened onto a slab
|
||||
plane. Solidify(use_rim) then grows its thickness outward (downward
|
||||
there), landing the shipped slab bottom exactly sole_drop below the skin
|
||||
sole."""
|
||||
lip_top = lm.skin_min_z + SOLE_LIP_TOP_M * lm.s
|
||||
lip = SOLE_LIP_M * lm.s
|
||||
slab_z = lm.skin_min_z - (sole_drop * lm.s - base.CLOTH_THICKNESS_M)
|
||||
feather = max(lip_top - lm.skin_min_z, 1e-6)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
n_lip = 0
|
||||
for v in bm.verts:
|
||||
if v.co.z < lip_top:
|
||||
t = min((lip_top - v.co.z) / feather, 1.0)
|
||||
nx, ny = v.normal.x, v.normal.y
|
||||
mag = (nx * nx + ny * ny) ** 0.5
|
||||
if mag > 1e-6:
|
||||
v.co.x += lip * t * nx / mag
|
||||
v.co.y += lip * t * ny / mag
|
||||
n_lip += 1
|
||||
n_flat = 0
|
||||
for v in bm.verts:
|
||||
if v.co.z < lm.skin_min_z:
|
||||
v.co.z = slab_z
|
||||
n_flat += 1
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"sole: lip +{lip * 1000:.1f} mm on {n_lip} verts, "
|
||||
f"{n_flat} under-sole verts flattened -> z={slab_z:.3f} "
|
||||
f"(shipped bottom {sole_drop * lm.s * 1000:.0f} mm below skin sole)")
|
||||
|
||||
|
||||
def clamp_rim_residue(shell, rim_z):
|
||||
"""Post-solidify safety clamp: verts still above the rim plane (solidify
|
||||
displacement on multi-triangle teeth) get squashed onto it."""
|
||||
me = shell.data
|
||||
n = 0
|
||||
for v in me.vertices:
|
||||
if v.co.z > rim_z:
|
||||
v.co.z = rim_z
|
||||
n += 1
|
||||
me.update()
|
||||
if n:
|
||||
log(f"clamped {n} residual rim verts -> {rim_z:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Boot feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _boot_field(px, py, pz, lm):
|
||||
"""Evaluate boot features at texel 3D positions (numpy arrays)."""
|
||||
s = lm.s
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
|
||||
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
r = np.hypot(dx, dy) + 1e-9
|
||||
# Arc distance from the front meridian around the per-z leg axis —
|
||||
# constant metric width at any girth, mirrors correctly on both boots.
|
||||
arc_front = np.arccos(np.clip(dy * FRONT_Y_SIGN / r, -1.0, 1.0)) * r
|
||||
|
||||
in_sole = pz <= lm.sole_top_z
|
||||
in_lace_z = (pz > lm.lace_lo) & (pz < lm.lace_hi) & ~in_sole
|
||||
|
||||
panel = in_lace_z & (arc_front < LACE_PANEL_HW_M * s)
|
||||
pitch = LACE_PITCH_M * s
|
||||
ph = np.mod(pz - lm.lace_lo, pitch)
|
||||
laces = panel & (np.abs(ph - 0.5 * pitch) < LACE_BAR_HW_M * s)
|
||||
eyelets = (
|
||||
in_lace_z
|
||||
& (np.abs(arc_front - LACE_PANEL_HW_M * s) < EYELET_R_M * s)
|
||||
& (np.abs(ph - 0.5 * pitch) < EYELET_R_M * s)
|
||||
)
|
||||
welt = (~in_sole) & (
|
||||
np.abs(pz - (lm.sole_top_z + WELT_OFF_M * s)) < SEAM_HW_M * s)
|
||||
if TOECAP_STITCH and lm.ball_front is not None:
|
||||
welt = welt | (
|
||||
(~in_sole) & (pz < lm.lace_lo)
|
||||
& (np.abs(py * FRONT_Y_SIGN - lm.ball_front) < SEAM_HW_M * s))
|
||||
collar = (~in_sole) & (pz > lm.rim_z - COLLAR_H_M * s)
|
||||
return in_sole, panel, laces, eyelets, welt, collar
|
||||
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
in_sole, panel, laces, eyelets, welt, collar = _boot_field(px, py, pz, lm)
|
||||
|
||||
# --- albedo ------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = LEATHER_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
alb[collar, :3] *= COLLAR_SHADE
|
||||
if not PLAIN:
|
||||
alb[panel, :3] *= PANEL_SHADE
|
||||
for c in range(3):
|
||||
alb[welt, c] = WELT_RGB[c]
|
||||
for c in range(3):
|
||||
alb[in_sole, c] = SOLE_RGB[c] + noise[in_sole]
|
||||
if not PLAIN:
|
||||
for c in range(3):
|
||||
alb[laces, c] = LACE_RGB[c]
|
||||
alb[eyelets, c] = EYELET_RGB[c]
|
||||
|
||||
# --- region mask: sole R / upper+shaft G / laces+eyelets B ---------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_b = (laces | eyelets) & ~in_sole if not PLAIN else np.zeros(n, dtype=bool)
|
||||
is_g = ~(in_sole | is_b)
|
||||
mask[in_sole, 0] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[is_g, 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# UV0 rasterization (denim technique: one pass paints albedo + mask)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the boot field, write albedo + mask together."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = LEATHER_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = upper green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"boot_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"boot_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_boot_shell(body_dir, out_dir, body, offset, sole_drop):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell) # ankle seams: foot <-> leg_lower
|
||||
|
||||
skin_min_z = min(v.co.z for v in shell.data.vertices)
|
||||
lm = BootLandmarks(armature, skin_min_z)
|
||||
|
||||
# Containment reference: the ORIGINAL welded skin (real toes), captured
|
||||
# before any shaping. The bmesh must outlive the BVH queries.
|
||||
skin_bvh, skin_bm = build_skin_bvh(shell)
|
||||
|
||||
toe_merge(shell, lm)
|
||||
shaft_cut(shell, lm)
|
||||
rim_z = flatten_shaft_rims(shell, lm)
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
# Toe-box merge can leave the smoothed+offset shell inside the real skin
|
||||
# toes; push foot-region verts back out to the minimum clearance, then
|
||||
# re-align their bone weights to the anatomy they now cover (flex fix).
|
||||
containment_clamp(shell, skin_bvh, CLAMP_CLEAR_FRAC * offset, lm.ankle_z)
|
||||
transfer_foot_weights(shell, skin_bm, skin_bvh, lm.ankle_z)
|
||||
skin_bm.free()
|
||||
shaft_flare(shell, lm, rim_z, SHAFT_FLARE_M * lm.s)
|
||||
sole_shape(shell, lm, offset, sole_drop)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
clamp_rim_residue(shell, rim_z)
|
||||
|
||||
lm.finalize(rim_z)
|
||||
denim.author_parked_uv2(shell) # boots are not logo-capable
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global SHAFT_FRAC, SHAFT_FLARE_M, LEATHER_RGB, PLAIN
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--shaft-frac F] [--shaft-flare M] "
|
||||
"[--sole-drop M] [--base-rgb r,g,b] [--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = BOOT_OFFSET_M
|
||||
sole_drop = SOLE_DROP_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--shaft-frac" in argv:
|
||||
SHAFT_FRAC = float(argv[argv.index("--shaft-frac") + 1])
|
||||
if "--shaft-flare" in argv:
|
||||
SHAFT_FLARE_M = float(argv[argv.index("--shaft-flare") + 1])
|
||||
if "--sole-drop" in argv:
|
||||
sole_drop = float(argv[argv.index("--sole-drop") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
LEATHER_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"boots per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{offset * 1000:.0f} mm, shaft-frac {SHAFT_FRAC}, "
|
||||
f"sole-drop {sole_drop * 1000:.0f} mm, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_boot_shell(body_dir, out_dir, body, offset, sole_drop)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,502 @@
|
||||
"""
|
||||
blender_author_cargo_pants.py (T-1089 wave 2, cargo_pants)
|
||||
|
||||
Authors full-length CARGO pants as per-body offset shells. Reuses
|
||||
blender_author_offset_shell.py as the base library (scene build, join,
|
||||
offset, solidify, GLB export) and blender_author_denim_pants.py as the
|
||||
proven bottoms companion (boundary WELD of coincident segment-seam rings,
|
||||
open-rim FLATTENING onto clean planes, per-body LegLandmarks, waist flare,
|
||||
hem cut, parked logo UV2) — the denim script's utilities are imported as a
|
||||
module, not re-derived.
|
||||
|
||||
What cargo adds, as reusable parameters:
|
||||
|
||||
* --straight: STRAIGHT-FIT boost — extra radial stand-off away from the
|
||||
per-z leg axis, ramping 0 at the knee to the full value at the ankle, so
|
||||
the lower leg does not taper with the calf (straight silhouette; also
|
||||
buys lower-leg clip clearance). Applied post-offset, pre-solidify.
|
||||
* TEXEL-level cargo feature painting (same one-field-drives-both design as
|
||||
denim: painted albedo and region mask always agree):
|
||||
- albedo: large outer-thigh CARGO POCKETS with button-down FLAPS
|
||||
(filled panels, border stitching, centre pleat, two flap
|
||||
buttons), outseam/inseam side seams, centre-front fly
|
||||
stitch, belt loops + waist button, waistband and hem border
|
||||
stitching — flat tone-on-tone utility look, identity
|
||||
carried by the texture (style pin: modern only).
|
||||
- mask: waistband -> R, legs -> G, pockets + flaps -> B
|
||||
(spec: waistband=R, legs=G, pockets+flaps=B).
|
||||
A SIGNED azimuth arc around the per-z leg axis (0 at the outer side,
|
||||
positive toward the front) places the pockets slightly forward-of-side
|
||||
and the flap buttons symmetrically — the same analytic-field approach as
|
||||
the denim script, generalised from unsigned to signed arc.
|
||||
* pocket proportions anchor to each body's own THIGH span (crotch ->
|
||||
knee) and hip half-width, so the pockets stay proportional across all
|
||||
11 bodies (child included) — the base.derive_thresholds philosophy.
|
||||
|
||||
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r; natural
|
||||
boundaries give the waist opening and ankle hems. Regions are painted, not
|
||||
modelled — no 3D pocket geometry (texture carries identity).
|
||||
|
||||
Usage (cargo_pants reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_cargo_pants.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/cargo_pants \
|
||||
[--bodies average_m,child,...] [--offset 0.013] [--hem-frac 1.0] \
|
||||
[--waist-flare 0.007] [--straight 0.010] [--base-rgb 0.36,0.37,0.29] \
|
||||
[--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module + the denim bottoms companion
|
||||
# (weld / rim-flatten / flare / landmarks utilities live there)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(name, fname):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, os.path.join(_HERE, fname))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load("offset_shell_base", "blender_author_offset_shell.py")
|
||||
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
|
||||
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_hips",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
]
|
||||
|
||||
HEM_FRAC = 1.0 # 1.0 = full leg to the ankle
|
||||
OFFSET_M = 0.013 # cargo sits a touch looser than jeans (0.012)
|
||||
STRAIGHT_M = 0.010 # straight-fit radial boost at the ankle (0 at knee)
|
||||
WAIST_FLARE_M = 0.007 # waistband rim stand-off (deep-crouch mitigation,
|
||||
# proven on jeans)
|
||||
TEX_SIZE = 1024 # painted pockets/seams need >512
|
||||
|
||||
# Vertical proportions — fractions of the garment span (waist_z - hem_z),
|
||||
# via denim.LegLandmarks.finalize (BAND/CUFF/SEAM fractions proven on jeans).
|
||||
# Pocket proportions — fractions of the THIGH span (crotch_z - knee_z).
|
||||
FLAP_TOP_FRAC = 0.18 # flap top below the crotch
|
||||
FLAP_H_FRAC = 0.14 # flap height
|
||||
POCKET_BOT_FRAC = 0.74 # pocket bottom below the crotch
|
||||
|
||||
# Horizontal proportions — signed azimuth ARC around the per-z leg axis
|
||||
# (metres on the reference body, scaled by the body's hip ratio lm.sh).
|
||||
POCKET_HALF_ARC_M = 0.058 # pocket half-width
|
||||
FLAP_EXTRA_ARC_M = 0.007 # flap overhangs the pocket by this much per side
|
||||
POCKET_FWD_BIAS_M = 0.010 # pocket centre sits slightly forward of the outseam
|
||||
BUTTON_R_M = 0.0075 # flap button radius
|
||||
LOOP_X_FRACS = (0.55, 1.30) # belt-loop |x| positions (fractions of hip_x)
|
||||
LOOP_W_M = 0.016 # belt loop width (m, scaled by hip ratio)
|
||||
|
||||
# Utility olive/grey style (sRGB floats; flat toon-friendly, tone-on-tone).
|
||||
CARGO_RGB = (0.360, 0.370, 0.290) # utility olive-grey
|
||||
THREAD_RGB = (0.225, 0.235, 0.185) # tone-on-tone darker stitching
|
||||
BUTTON_RGB = (0.170, 0.160, 0.140) # matte button
|
||||
ALBEDO_NOISE = 0.020 # +/- woven jitter
|
||||
POCKET_SHADE = 0.93 # pocket panel albedo darkening
|
||||
FLAP_SHADE = 0.85 # flap panel albedo darkening
|
||||
LOOP_SHADE = 0.80 # belt-loop albedo darkening
|
||||
PLAIN = False # --plain: skip pockets/stitch/button paint
|
||||
NOISE_SEED = 3089
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: straight-fit boost (cargo's reusable fit parameter)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def straight_boost(shell, lm, boost):
|
||||
"""Extra RADIAL stand-off away from the per-z leg axis below the knee,
|
||||
ramping linearly from 0 at the knee to `boost` at the ankle. The body
|
||||
calf tapers; pushing the shell out progressively keeps the pant leg
|
||||
straight (cargo silhouette) instead of hugging the calf. Applied
|
||||
post-offset, pre-solidify; weights/UVs are untouched."""
|
||||
if boost <= 0.0:
|
||||
return
|
||||
knee_z = float(lm.leg_z_pts[1])
|
||||
ankle_z = float(lm.leg_z_pts[0])
|
||||
span = max(knee_z - ankle_z, 1e-6)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
n = 0
|
||||
for v in bm.verts:
|
||||
z = v.co.z
|
||||
if z >= knee_z:
|
||||
continue
|
||||
t = min((knee_z - z) / span, 1.0)
|
||||
side = 1.0 if v.co.x >= 0.0 else -1.0
|
||||
cx = side * float(np.interp(z, lm.leg_z_pts, lm.leg_x_pts))
|
||||
cy = float(np.interp(z, lm.leg_z_pts, lm.leg_y_pts))
|
||||
dx = v.co.x - cx
|
||||
dy = v.co.y - cy
|
||||
r = (dx * dx + dy * dy) ** 0.5
|
||||
if r > 1e-6:
|
||||
v.co.x += boost * t * dx / r
|
||||
v.co.y += boost * t * dy / r
|
||||
n += 1
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"straight-fit boost: {n} verts, +{boost * 1000:.1f} mm radial at "
|
||||
f"ankle (ramped from knee z={knee_z:.3f})")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Cargo feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _cargo_field(px, py, pz, lm):
|
||||
"""Evaluate cargo features at texel 3D positions (numpy arrays).
|
||||
|
||||
Returns a dict of bool arrays. `pocket`/`flap` feed the mask B channel;
|
||||
the stitch/fill features feed the albedo only.
|
||||
"""
|
||||
w2 = lm.seam_w * 0.5
|
||||
front = py * FRONT_Y_SIGN > 0.004
|
||||
in_band = pz >= lm.band_z
|
||||
mid = (~in_band) & (pz > lm.hem_z + 0.5 * lm.cuff_h)
|
||||
|
||||
# Per-z leg axis, mirrored by x sign; SIGNED azimuth arc around it
|
||||
# (0 at the outer side, positive toward the front, +/-pi*r at the inseam).
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
|
||||
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
r = np.hypot(dx, dy) + 1e-9
|
||||
theta = np.arctan2(dy * FRONT_Y_SIGN / r, np.clip(dx * side / r, -1.0, 1.0))
|
||||
sarc = theta * r # signed arc distance from the outer direction
|
||||
|
||||
# Side seams: outseam on the outer azimuth; inseam opposite, below crotch.
|
||||
outseam = mid & (np.abs(sarc) < w2)
|
||||
arc_in = (np.pi - np.abs(theta)) * r
|
||||
inseam = mid & (arc_in < w2) \
|
||||
& (pz < lm.crotch_z - 0.01 * lm.span / denim._REF_SPAN)
|
||||
|
||||
# Centre-front fly stitch (slightly off-centre, like the jeans J-front).
|
||||
fly = front & mid & (np.abs(px - 0.012 * lm.sh) < w2) \
|
||||
& (pz > lm.crotch_z + 0.015 * lm.span / denim._REF_SPAN)
|
||||
|
||||
# --- cargo pocket + flap (outer thigh, slightly forward-of-side) --------
|
||||
knee_z = float(lm.leg_z_pts[1])
|
||||
thigh = max(lm.crotch_z - knee_z, 1e-6)
|
||||
flap_top = lm.crotch_z - FLAP_TOP_FRAC * thigh
|
||||
flap_bot = flap_top - FLAP_H_FRAC * thigh
|
||||
pocket_bot = lm.crotch_z - POCKET_BOT_FRAC * thigh
|
||||
p_arc = POCKET_HALF_ARC_M * lm.sh
|
||||
f_arc = p_arc + FLAP_EXTRA_ARC_M * lm.sh
|
||||
a = sarc - POCKET_FWD_BIAS_M * lm.sh # pocket-centred signed arc
|
||||
|
||||
pocket = (np.abs(a) < p_arc) & (pz <= flap_top) & (pz >= pocket_bot)
|
||||
flap = (np.abs(a) < f_arc) & (pz <= flap_top) & (pz >= flap_bot)
|
||||
|
||||
# Pocket border stitching (sides + bottom), centre pleat, flap edge.
|
||||
p_side = (np.abs(np.abs(a) - p_arc) < w2) & (pz <= flap_top) \
|
||||
& (pz >= pocket_bot)
|
||||
p_bottom = (np.abs(pz - pocket_bot) < w2) & (np.abs(a) < p_arc)
|
||||
pleat = (np.abs(a) < w2) & (pz < flap_bot - 2.0 * w2) & (pz >= pocket_bot)
|
||||
f_edge = flap & ((np.abs(np.abs(a) - f_arc) < w2)
|
||||
| (np.abs(pz - flap_bot) < w2))
|
||||
pkt_stitch = p_side | p_bottom | pleat | f_edge
|
||||
|
||||
# Two flap buttons, symmetric about the pocket centre.
|
||||
btn_r = BUTTON_R_M * lm.sh
|
||||
btn_z = flap_bot + 0.30 * (flap_top - flap_bot)
|
||||
buttons = np.zeros_like(front)
|
||||
for bx in (-0.45 * f_arc, 0.45 * f_arc):
|
||||
buttons |= np.hypot(a - bx, pz - btn_z) < btn_r
|
||||
|
||||
# Border stitching: waistband seam + hem stitch.
|
||||
wstitch = np.abs(pz - lm.band_z) < w2
|
||||
hemstitch = np.abs(pz - lm.cuff_top) < w2
|
||||
|
||||
return {
|
||||
"front": front, "in_band": in_band,
|
||||
"seams": outseam | inseam | fly,
|
||||
"wstitch": wstitch, "hemstitch": hemstitch,
|
||||
"pocket": pocket, "flap": flap,
|
||||
"pkt_stitch": pkt_stitch, "buttons": buttons,
|
||||
}
|
||||
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
f = _cargo_field(px, py, pz, lm)
|
||||
|
||||
# --- albedo -------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = CARGO_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
|
||||
if not PLAIN:
|
||||
# Belt loops: darkened bands on the waistband.
|
||||
loop_w = LOOP_W_M * lm.sh
|
||||
loops = np.zeros(n, dtype=bool)
|
||||
for fx in LOOP_X_FRACS:
|
||||
loops |= np.abs(np.abs(px) - fx * lm.hip_x) < loop_w * 0.5
|
||||
loops |= (~f["front"]) & (np.abs(px) < loop_w * 0.5) # centre-back
|
||||
loops &= f["in_band"]
|
||||
alb[loops, :3] *= LOOP_SHADE
|
||||
|
||||
# Panel fills first, stitch lines on top.
|
||||
alb[f["pocket"] & ~f["flap"], :3] *= POCKET_SHADE
|
||||
alb[f["flap"], :3] *= FLAP_SHADE
|
||||
|
||||
thread = f["seams"] | f["wstitch"] | f["hemstitch"] | f["pkt_stitch"]
|
||||
alb[thread, 0] = THREAD_RGB[0]
|
||||
alb[thread, 1] = THREAD_RGB[1]
|
||||
alb[thread, 2] = THREAD_RGB[2]
|
||||
|
||||
# Flap buttons + waist button.
|
||||
btn = f["buttons"] | (f["front"] & (
|
||||
np.hypot(px, pz - (lm.band_z + 0.5 * lm.band_h)) < 0.009 * lm.sh))
|
||||
alb[btn, 0] = BUTTON_RGB[0]
|
||||
alb[btn, 1] = BUTTON_RGB[1]
|
||||
alb[btn, 2] = BUTTON_RGB[2]
|
||||
|
||||
# --- region mask: waistband R / legs G / pockets+flaps B -----------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_b = (f["pocket"] | f["flap"]) & ~f["in_band"]
|
||||
is_g = ~(f["in_band"] | is_b)
|
||||
mask[f["in_band"], 0] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[is_g, 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the cargo field, write albedo + mask together (same rasterizer
|
||||
contract as the denim companion, pointed at the cargo painter)."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = CARGO_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = legs green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"cargo_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"cargo_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_cargo_shell(body_dir, out_dir, body, offset, hem_frac):
|
||||
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
|
||||
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell)
|
||||
|
||||
zs = [v.co.z for v in shell.data.vertices]
|
||||
waist_z, ankle_z = max(zs), min(zs)
|
||||
lm = denim.LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
|
||||
|
||||
hem_z = denim.hem_cut(shell, lm, hem_frac)
|
||||
ankle_plane = float(lm.leg_z_pts[0]) if hem_frac >= 0.999 else hem_z
|
||||
waist_plane, ankle_plane = denim.flatten_open_rims(shell, ankle_plane)
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
straight_boost(shell, lm, STRAIGHT_M)
|
||||
denim.waist_flare(shell, waist_plane,
|
||||
denim.BAND_FRAC * (waist_plane - ankle_plane),
|
||||
WAIST_FLARE_M)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
denim.clamp_waist_residue(shell, waist_plane)
|
||||
|
||||
# Landmarks reference the CLEAN rims (band under the flattened waist edge,
|
||||
# hem stitch above the flattened ankle rim).
|
||||
lm.waist_z = waist_plane
|
||||
lm.finalize(ankle_plane)
|
||||
denim.author_parked_uv2(shell)
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global HEM_FRAC, CARGO_RGB, PLAIN, WAIST_FLARE_M, STRAIGHT_M
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--hem-frac F] [--waist-flare M] [--straight M] "
|
||||
"[--base-rgb r,g,b] [--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--hem-frac" in argv:
|
||||
HEM_FRAC = float(argv[argv.index("--hem-frac") + 1])
|
||||
if "--waist-flare" in argv:
|
||||
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
|
||||
if "--straight" in argv:
|
||||
STRAIGHT_M = float(argv[argv.index("--straight") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
CARGO_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"cargo per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{offset * 1000:.0f} mm, straight {STRAIGHT_M * 1000:.0f} mm, "
|
||||
f"hem-frac {HEM_FRAC}, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_cargo_shell(body_dir, out_dir, body, offset, HEM_FRAC)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
blender_author_joggers.py (T-1089 wave 2, joggers_modern + sweatpant family)
|
||||
|
||||
Authors JOGGERS/sweatpants as per-body offset shells: hips + full legs,
|
||||
tapered toward the ankle into snug cuff bands, painted waist drawstring, and
|
||||
a track SIDE STRIPE carried as its own tint region. Reuses
|
||||
blender_author_offset_shell.py (via blender_author_denim_pants.py's module
|
||||
instance) for scene build / join / solidify / export, and reuses the denim
|
||||
companion's REQUIRED lower-body practices directly (not re-derived):
|
||||
|
||||
* boundary WELD of coincident segment-seam rings before offsetting
|
||||
(denim.weld_boundaries) — un-welded rings offset apart along diverging
|
||||
normals and open cracks at the waist/knee joins.
|
||||
* open-rim FLATTENING (denim.flatten_open_rims) — the segment splitter
|
||||
leaves 4.5-7.6 cm jagged teeth at the waist/ankle rims; boundary verts are
|
||||
pulled onto clean planes pre-offset. The flattened ankle plane IS the
|
||||
jogger cuff hem.
|
||||
* waist flare + post-solidify residue clamp (denim.waist_flare,
|
||||
denim.clamp_waist_residue) — deep-crouch waist-fold clip mitigation.
|
||||
* per-body leg-axis landmarks (denim.LegLandmarks) — thigh_l/calf_l control
|
||||
points drive azimuth math for the side stripe and the cuff/band rib paint,
|
||||
so every parameter derives from the body's own bones (Q-060 per-body mode).
|
||||
|
||||
What this companion adds, as reusable parameters (not hacks):
|
||||
|
||||
* TAPERED variable offset — constant baggy standoff (--offset) above the
|
||||
knee, smoothstep taper to --mid-offset at the cuff top, then a short
|
||||
feathered step down to the snug --cuff-offset inside the cuff band: the
|
||||
sweatpant silhouette (baggy thigh -> tapered shin -> gripping cuff).
|
||||
* ankle CUFF band (--cuff-frac of the garment span) — its own mask region
|
||||
(A channel) with painted knit RIB (azimuth-arc alternating shades, constant
|
||||
metric rib width via the per-z leg axis).
|
||||
* SIDE STRIPE (--stripe-width metres, scaled by hip width) — constant-width
|
||||
azimuth band along the outer leg from waistband to cuff, painted bright
|
||||
AND masked to its own region (B channel) for independent tinting.
|
||||
* painted elastic waistband (subtle vertical rib) + centre-front DRAWSTRING:
|
||||
two eyelets and two hanging cords with a slight outward slant (albedo
|
||||
only; dark cords stay legible under the luma-scaled region tint).
|
||||
|
||||
Regions (RGBA mask, toon_garment.gdshader): waistband -> R (tint_0),
|
||||
legs -> G (tint_1), side stripe -> B (tint_2), ankle cuffs -> A (tint_3).
|
||||
A parked logo_uv TEXCOORD_1 layer is authored (denim.author_parked_uv2) —
|
||||
not logo-capable, but the shader samples UV2 unconditionally.
|
||||
|
||||
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r. Natural
|
||||
boundaries give the waist opening and ankle hems for free. Per-body mode only
|
||||
(offset shells author per body, Q-060).
|
||||
|
||||
Usage (joggers_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_joggers.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/joggers_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.014] [--mid-offset 0.010] \
|
||||
[--cuff-offset 0.0065] [--band-frac 0.055] [--cuff-frac 0.075] \
|
||||
[--stripe-width 0.034] [--fabric 0.55,0.56,0.58] \
|
||||
[--stripe-rgb 0.88,0.89,0.91] [--waist-flare 0.007] [--seed N] [--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the denim companion as a module — it carries the shared lower-body
|
||||
# practices (weld, rim flatten, waist flare, clamp, landmarks, parked UV2) and
|
||||
# its own instance of the base offset-shell library.
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"denim_pants", os.path.join(_HERE, "blender_author_denim_pants.py"))
|
||||
denim = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(denim)
|
||||
|
||||
base = denim.base # single shared base-module instance
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (defaults = joggers_modern)
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_hips",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
]
|
||||
|
||||
TEX_SIZE = 1024 # albedo + mask resolution (rib + cords need >512)
|
||||
WAIST_FLARE_M = 0.007 # denim-proven deep-crouch waist-fold mitigation
|
||||
|
||||
# Tapered-offset profile (metres).
|
||||
OFFSET_HI_M = 0.014 # hips/thigh standoff — baggy sweatpant volume
|
||||
OFFSET_MID_M = 0.010 # taper target at the cuff top (shin)
|
||||
OFFSET_CUFF_M = 0.0065 # snug cuff standoff (ankles barely deform)
|
||||
CUFF_STEP_W_M = 0.010 # feather width of the taper->cuff step
|
||||
|
||||
# Vertical proportions — fractions of the garment span (waist_z - hem_z).
|
||||
BAND_FRAC = 0.055 # elastic waistband height (R region)
|
||||
CUFF_FRAC = 0.075 # knit cuff band height (A region) — taller than a hem
|
||||
SEAM_W_FRAC = 0.008 # painted border-stitch line width
|
||||
CORD_DROP_FRAC = 0.050 # drawstring cord length below the waistband
|
||||
CORD_SLANT = 0.18 # outward cord slant (dx per unit hang depth)
|
||||
|
||||
# Horizontal / arc-metric proportions (scaled by hip half-width ratio lm.sh).
|
||||
STRIPE_W_M = 0.034 # side stripe metric width (B region)
|
||||
RIB_PERIOD_M = 0.009 # cuff knit-rib period (arc metres)
|
||||
BAND_RIB_PERIOD_M = 0.006 # waistband elastic-rib period (arc metres)
|
||||
CORD_X_M = 0.016 # drawstring eyelet/cord |x| at the band
|
||||
CORD_W_M = 0.005 # cord width
|
||||
EYELET_R_M = 0.0045 # eyelet dot radius
|
||||
|
||||
# Fleece style (sRGB floats; flat toon-friendly, texture carries identity).
|
||||
FLEECE_RGB = (0.55, 0.56, 0.58) # heather grey, luma ~0.56 (tint-faithful)
|
||||
STRIPE_RGB = (0.88, 0.89, 0.91) # near-white track stripe
|
||||
CORD_RGB = (0.17, 0.17, 0.19) # charcoal drawstring
|
||||
ALBEDO_NOISE = 0.020 # +/- fleece jitter
|
||||
BAND_SHADE = 0.94 # waistband overall darkening
|
||||
CUFF_SHADE = 0.96 # cuff overall darkening
|
||||
RIB_LO = 0.86 # cuff rib dark-stripe factor
|
||||
BAND_RIB_LO = 0.94 # waistband rib dark-stripe factor
|
||||
STITCH_SHADE = 0.80 # band/cuff border-stitch darkening
|
||||
PLAIN = False # --plain: skip drawstring/rib/stitch paint
|
||||
NOISE_SEED = 2090 # distinct from denim (2089)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Joggers landmark finalization (denim.LegLandmarks carries the leg axis; the
|
||||
# band/cuff fractions here are jogger parameters, not denim's)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def finalize_jogger_landmarks(lm, waist_plane, ankle_plane,
|
||||
band_frac, cuff_frac):
|
||||
lm.waist_z = waist_plane
|
||||
lm.hem_z = ankle_plane
|
||||
lm.span = waist_plane - ankle_plane
|
||||
lm.sh = lm.hip_x / denim._REF_HIP_X
|
||||
lm.band_h = band_frac * lm.span
|
||||
lm.cuff_h = cuff_frac * lm.span
|
||||
lm.seam_w = SEAM_W_FRAC * lm.span
|
||||
lm.band_z = waist_plane - lm.band_h
|
||||
lm.cuff_top = ankle_plane + lm.cuff_h
|
||||
log(f"landmarks: waist={lm.waist_z:.3f} hem={lm.hem_z:.3f} "
|
||||
f"span={lm.span:.3f} hip_x={lm.hip_x:.3f} band_z={lm.band_z:.3f} "
|
||||
f"cuff_top={lm.cuff_top:.3f} sh={lm.sh:.2f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tapered offset — the jogger silhouette
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _smoothstep(u):
|
||||
u = min(max(u, 0.0), 1.0)
|
||||
return u * u * (3.0 - 2.0 * u)
|
||||
|
||||
|
||||
def tapered_offset(shell, knee_z, cuff_top, off_hi, off_mid, off_cuff,
|
||||
step_w):
|
||||
"""Per-vertex outward offset along smoothed normals: `off_hi` above the
|
||||
knee, smoothstep taper to `off_mid` at the cuff top, then a feathered step
|
||||
down to the snug `off_cuff` inside the cuff band. The step reads as the
|
||||
cuff ledge; the A-region tint + painted rib carry the rest."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.normal_update()
|
||||
denom = max(knee_z - cuff_top, 1e-6)
|
||||
lo = hi = None
|
||||
for v in bm.verts:
|
||||
z = v.co.z
|
||||
t = off_hi + (off_mid - off_hi) * _smoothstep((knee_z - z) / denom)
|
||||
s = _smoothstep((cuff_top + step_w - z) / step_w)
|
||||
t = t + (off_cuff - t) * s
|
||||
v.co += v.normal * t
|
||||
lo = t if lo is None else min(lo, t)
|
||||
hi = t if hi is None else max(hi, t)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"tapered offset: {hi * 1000:.1f} mm (thigh) -> {lo * 1000:.1f} mm "
|
||||
f"(cuff); knee_z={knee_z:.3f} cuff_top={cuff_top:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Jogger feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _jogger_field(px, py, pz, lm):
|
||||
"""Evaluate jogger features at texel 3D positions (numpy arrays).
|
||||
|
||||
Returns (front, in_band, in_cuff, stripe, arc): `stripe` feeds the mask B
|
||||
channel, `in_band`/`in_cuff` feed R/A, `arc` is the azimuth arc-length
|
||||
coordinate around the per-z leg axis used by the rib paint.
|
||||
"""
|
||||
front = py * FRONT_Y_SIGN > 0.004
|
||||
in_band = pz >= lm.band_z
|
||||
in_cuff = pz <= lm.cuff_top
|
||||
mid = (~in_band) & (~in_cuff)
|
||||
|
||||
# Per-z leg axis, mirrored by x sign (denim.LegLandmarks control points).
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
|
||||
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
r = np.hypot(dx, dy) + 1e-9
|
||||
|
||||
# Side stripe: azimuth toward the outer (+/-x) direction; constant metric
|
||||
# width via arc distance (same construction as the denim outseam).
|
||||
arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
|
||||
stripe = mid & (arc_out < 0.5 * STRIPE_W_M * lm.sh)
|
||||
|
||||
# Rib coordinate: signed azimuth arc length (0 at the outseam; the +/-pi
|
||||
# wrap sits at the inner ankle / body centre where it cannot be seen).
|
||||
arc = np.arctan2(dy, dx * side) * r
|
||||
return front, in_band, in_cuff, stripe, arc
|
||||
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
front, in_band, in_cuff, stripe, arc = _jogger_field(px, py, pz, lm)
|
||||
|
||||
# --- albedo ------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = FLEECE_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
|
||||
# Side stripe: bright, low-noise (crisp edge carries the sport read).
|
||||
for c in range(3):
|
||||
alb[stripe, c] = STRIPE_RGB[c] + 0.5 * noise[stripe]
|
||||
|
||||
if not PLAIN:
|
||||
# Cuff knit rib: alternating shade stripes, constant metric width.
|
||||
rib_dark = (np.floor(arc / (RIB_PERIOD_M * lm.sh)).astype(np.int64)
|
||||
% 2) == 0
|
||||
cuff_f = np.where(rib_dark, RIB_LO, 1.0) * CUFF_SHADE
|
||||
alb[in_cuff, :3] *= cuff_f[in_cuff, None]
|
||||
|
||||
# Waistband elastic rib: same construction, subtler.
|
||||
band_dark = (np.floor(arc / (BAND_RIB_PERIOD_M * lm.sh))
|
||||
.astype(np.int64) % 2) == 0
|
||||
band_f = np.where(band_dark, BAND_RIB_LO, 1.0) * BAND_SHADE
|
||||
alb[in_band, :3] *= band_f[in_band, None]
|
||||
|
||||
# Border stitching: darker lines under the band and above the cuff.
|
||||
w2 = lm.seam_w * 0.5
|
||||
stitch = (np.abs(pz - lm.band_z) < w2) | (np.abs(pz - lm.cuff_top) < w2)
|
||||
alb[stitch, :3] *= STITCH_SHADE
|
||||
|
||||
# Drawstring: two eyelets on the band + two slanted hanging cords.
|
||||
ex = CORD_X_M * lm.sh
|
||||
eyelet_z = lm.band_z + 0.35 * lm.band_h
|
||||
eyelets = front & (
|
||||
np.hypot(np.abs(px) - ex, pz - eyelet_z) < EYELET_R_M * lm.sh)
|
||||
drop = CORD_DROP_FRAC * lm.span
|
||||
hang = np.clip(eyelet_z - pz, 0.0, None)
|
||||
cord_cx = ex + CORD_SLANT * hang
|
||||
cords = front & (pz > eyelet_z - drop) & (pz <= eyelet_z) \
|
||||
& (np.abs(np.abs(px) - cord_cx) < 0.5 * CORD_W_M * lm.sh)
|
||||
for c in range(3):
|
||||
alb[eyelets | cords, c] = CORD_RGB[c]
|
||||
else:
|
||||
alb[in_cuff, :3] *= CUFF_SHADE
|
||||
alb[in_band, :3] *= BAND_SHADE
|
||||
|
||||
# --- region mask: band R / legs G / stripe B / cuffs A -------------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_b = stripe & ~in_band & ~in_cuff
|
||||
is_g = ~(in_band | in_cuff | is_b)
|
||||
mask[in_band, 0] = 1.0
|
||||
mask[is_g, 1] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[in_cuff, 3] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the jogger field, write albedo + mask together (same rasterizer
|
||||
construction as the denim companion; the paint field is what differs)."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = FLEECE_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = legs green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"jogger_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"jogger_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_jogger_shell(body_dir, out_dir, body):
|
||||
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
|
||||
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell)
|
||||
|
||||
zs = [v.co.z for v in shell.data.vertices]
|
||||
waist_z, ankle_z = max(zs), min(zs)
|
||||
lm = denim.LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
|
||||
|
||||
# Full-length hem AT the ankle joint (calf tail); rim teeth flattened onto
|
||||
# clean planes pre-offset (denim practice — no jagged silhouette).
|
||||
ankle_plane = float(lm.leg_z_pts[0])
|
||||
waist_plane, ankle_plane = denim.flatten_open_rims(shell, ankle_plane)
|
||||
|
||||
span = waist_plane - ankle_plane
|
||||
knee_z = float(lm.leg_z_pts[1]) # calf head
|
||||
cuff_top = ankle_plane + CUFF_FRAC * span
|
||||
tapered_offset(shell, knee_z, cuff_top, OFFSET_HI_M, OFFSET_MID_M,
|
||||
OFFSET_CUFF_M, CUFF_STEP_W_M)
|
||||
denim.waist_flare(shell, waist_plane, BAND_FRAC * span, WAIST_FLARE_M)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
denim.clamp_waist_residue(shell, waist_plane)
|
||||
|
||||
# Landmarks reference the CLEAN rims for the paint/mask pass.
|
||||
finalize_jogger_landmarks(lm, waist_plane, ankle_plane, BAND_FRAC,
|
||||
CUFF_FRAC)
|
||||
denim.author_parked_uv2(shell)
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global OFFSET_HI_M, OFFSET_MID_M, OFFSET_CUFF_M, BAND_FRAC, CUFF_FRAC
|
||||
global STRIPE_W_M, FLEECE_RGB, STRIPE_RGB, WAIST_FLARE_M, NOISE_SEED, PLAIN
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--mid-offset M] [--cuff-offset M] "
|
||||
"[--band-frac F] [--cuff-frac F] [--stripe-width M] "
|
||||
"[--fabric r,g,b] [--stripe-rgb r,g,b] [--waist-flare M] "
|
||||
"[--seed N] [--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
if "--offset" in argv:
|
||||
OFFSET_HI_M = float(argv[argv.index("--offset") + 1])
|
||||
if "--mid-offset" in argv:
|
||||
OFFSET_MID_M = float(argv[argv.index("--mid-offset") + 1])
|
||||
if "--cuff-offset" in argv:
|
||||
OFFSET_CUFF_M = float(argv[argv.index("--cuff-offset") + 1])
|
||||
if "--band-frac" in argv:
|
||||
BAND_FRAC = float(argv[argv.index("--band-frac") + 1])
|
||||
if "--cuff-frac" in argv:
|
||||
CUFF_FRAC = float(argv[argv.index("--cuff-frac") + 1])
|
||||
if "--stripe-width" in argv:
|
||||
STRIPE_W_M = float(argv[argv.index("--stripe-width") + 1])
|
||||
if "--fabric" in argv:
|
||||
FLEECE_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--fabric") + 1].split(","))
|
||||
if "--stripe-rgb" in argv:
|
||||
STRIPE_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--stripe-rgb") + 1].split(","))
|
||||
if "--waist-flare" in argv:
|
||||
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
|
||||
if "--seed" in argv:
|
||||
NOISE_SEED = int(argv[argv.index("--seed") + 1])
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"joggers per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{OFFSET_HI_M * 1000:.0f}->{OFFSET_CUFF_M * 1000:.1f} mm, "
|
||||
f"band_frac={BAND_FRAC} cuff_frac={CUFF_FRAC} "
|
||||
f"stripe={STRIPE_W_M * 1000:.0f} mm, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_jogger_shell(body_dir, out_dir, body)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,726 @@
|
||||
"""
|
||||
blender_author_parka.py (T-1089 wave 2 — parka_thrds, hip-length insulated parka)
|
||||
|
||||
Cold-weather parka authored as per-body offset shells — the FIRST CANON-BRANDED
|
||||
garment (thrds, the Braemar fiber cooperative, wiki/star-systems/GJ-475).
|
||||
Companion to blender_author_offset_shell.py (imported as a library): reuses the
|
||||
base module's segment join / bone-ratio thresholds / offset / solidify /
|
||||
logo-UV2 / export machinery and layers the parka geometry + texture identity on
|
||||
top, combining the proven practices of the wave-1 family:
|
||||
|
||||
* HIP-LENGTH — covers torso + torso_upper + FULL arms + the TOP of the hips
|
||||
zone: seg_hips joins the shell, the coincident torso|hips overlap band is
|
||||
WELDED before offsetting (denim practice — un-welded rings offset along
|
||||
diverging normals and open cracks), and the hem is cut at a bone-derived
|
||||
plane between the pelvis head and spine_01 head (no skirt panel below).
|
||||
The vert-threshold hem cut leaves jagged teeth, so the open hem rim is
|
||||
FLATTENED onto its own valley plane (denim flatten practice) for a clean
|
||||
straight hem, which Solidify(use_rim) then caps.
|
||||
* HOOD-DOWN ROLL — the hoodie's rolled-collar inflation with bulkier parka
|
||||
parameters (radial bulge away from the neck axis, back-biased, rim lift).
|
||||
* CHUNKY INSULATED READ — painted, not modelled: horizontal quilt channel
|
||||
lines (constant-z on the body, constant-|x| around the sleeves) with a
|
||||
soft per-channel "puff" luminance gradient; big front patch pockets with
|
||||
flaps; a storm-flap front placket with bright zip-teeth dashes; hem
|
||||
drawcord band; sleeve cuff bands. All identity is carried as LUMINANCE so
|
||||
the luma-preserving toon_garment recolor keeps it under any tint.
|
||||
* REGIONS (spec): R = hood roll + collar, G = body, B = sleeves,
|
||||
A = pocket / quilt trim (quilt lines, pockets, storm flap, cuff bands).
|
||||
Albedo and mask come from ONE shared per-texel feature-field evaluation
|
||||
(denim practice) so they always agree. The mask ships CHANNEL_PACKED with
|
||||
an alpha floor (coverall lesson: Godot's fix_alpha_border pass + Blender's
|
||||
straight-alpha PNG save both destroy a plain A channel).
|
||||
* LOGO-CAPABLE — left-breast chest patch (the storm flap owns the centre
|
||||
line), same bone-ratio box as jacket_modern; painted as a brighter patch
|
||||
rectangle kept in the G region so the decal reads on the body tint.
|
||||
|
||||
Per-body only (Q-060: offset shells author per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_parka.py -- \
|
||||
client/assets/characters/bodies client/assets/characters/clothing/parka_thrds \
|
||||
[--bodies a,b,c] [--offset 0.026] [--hem-frac 0.15]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, painted albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, that body's UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Load the base offset-shell module (shared engine machinery).
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
|
||||
base = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(base)
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[parka] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PARAMS — parka_thrds. A parka-family variant should only touch this block.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
GARMENT_ID = "parka_thrds"
|
||||
|
||||
SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
"seg_hips",
|
||||
]
|
||||
|
||||
OFFSET_M = 0.026 # chunkiest standoff of the tops family — an insulated
|
||||
# parka worn over layers (jacket_modern is 0.022)
|
||||
THICKNESS_M = 0.008 # insulated cloth (jacket 5 mm, hoodie 6 mm)
|
||||
WELD_DIST = 5e-4 # coincident segment-seam weld tolerance (denim)
|
||||
|
||||
# Hem: fraction of the pelvis->spine_01 span ABOVE the pelvis head. 0.15 lands
|
||||
# just below the hip joint (thigh head) on every body — hip-length, no skirt.
|
||||
HEM_PELVIS_FRAC = 0.15
|
||||
HEM_ALLOW_FRAC = 0.50 # hem-ring search band above the cut plane (same span)
|
||||
|
||||
# Sleeves: wrist cut + cuff band (jacket/hoodie practice — cuff anchored to the
|
||||
# ACTUAL post-cut mesh reach, the jagged cut stops 1-3 cm short of the plane).
|
||||
CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept
|
||||
CUFF_LEN_FRAC = 0.14 # cuff band length as fraction of the lowerarm
|
||||
|
||||
# Collar / hood-down roll (fractions of neck_01 length unless noted).
|
||||
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below the neck head
|
||||
COLLAR_X_MULT = 1.20 # parka collar wider than the tee band
|
||||
ROLL_OUT_FRAC = 0.50 # radial bulge magnitude (hoodie 0.40 — bulkier)
|
||||
ROLL_LIFT_FRAC = 0.16 # upward lift at the rim
|
||||
ROLL_Z_START_FRAC = 0.50 # roll influence starts this far below collar_z_min
|
||||
ROLL_REACH_COLLAR_X = 1.80 # candidate radius around neck axis (x tee collar_x)
|
||||
ROLL_FRONT_GAIN = 0.60 # bulge scale at the front...
|
||||
ROLL_BACK_GAIN = 1.25 # ...and the back (hood mass hangs behind)
|
||||
ROLL_EXPONENT = 1.80 # falloff sharpness toward the rim
|
||||
R_Z_DROP_FRONT = 0.20 # R region drop below collar_z_min (x neck_len)
|
||||
R_Z_DROP_BACK = 0.60 # asymmetric — the roll drapes lower on the back
|
||||
|
||||
# Storm flap + painted zip (metric refs scale by shoulder |x| / 0.1919).
|
||||
FLAP_HALF_FRAC = 0.026 / 0.1919 # storm-flap half-width, frac of shoulder |x|
|
||||
TEETH_HALF_M_REF = 0.0065 # zip teeth strip half-width
|
||||
TEETH_PERIOD_M_REF = 0.026 # dash period along Z
|
||||
TEETH_DUTY_M_REF = 0.015 # bright dash length within a period
|
||||
|
||||
# Big front patch pockets (x fracs of shoulder |x|, z fracs of garment span).
|
||||
POCKET_CX_FRAC = 0.44
|
||||
POCKET_HW_FRAC = 0.25
|
||||
POCKET_Z0_FRAC = 0.06 # clear of the hem drawcord band (HEM_BAND_FRAC 0.045)
|
||||
POCKET_Z1_FRAC = 0.255
|
||||
POCKET_FLAP_FRAC = 0.30 # top fraction of the pocket = flap
|
||||
|
||||
# Quilt channels (fracs of the garment span = collar_z_min - hem).
|
||||
QUILT_PERIOD_FRAC = 0.13
|
||||
QUILT_HALFW_FRAC = 0.0070 # painted line half-width
|
||||
HEM_BAND_FRAC = 0.045 # drawcord hem band height
|
||||
PUFF_AMP = 0.045 # per-channel puff luminance amplitude
|
||||
|
||||
# Left-breast logo patch (character-left = +X; the flap owns the centre line).
|
||||
# Same average_m absolutes as jacket_modern (0.035..0.115 m, z 1.30..1.42 m).
|
||||
CHEST_X_LO_FRAC = 0.035 / 0.1919
|
||||
CHEST_X_HI_FRAC = 0.115 / 0.1919
|
||||
CHEST_Z_LO_FRAC = (1.30 - 1.072) / (1.5205 - 1.072)
|
||||
CHEST_Z_HI_FRAC = (1.42 - 1.072) / (1.5205 - 1.072)
|
||||
|
||||
# Painted luma palette (toon_garment recolors by luma: tint * (luma*1.5+0.2)).
|
||||
BASE_LUMA = 0.55
|
||||
QUILT_LUMA = 0.40 # quilt channel lines
|
||||
ROLL_LUMA = 0.62 # hood roll reads lighter/lofted
|
||||
FLAP_LUMA = 0.50 # storm flap band
|
||||
POCKET_LUMA = 0.60 # pocket patch fill
|
||||
POCKET_FLAP_LUMA = 0.47 # pocket flap
|
||||
PATCH_LUMA = 0.68 # chest logo patch backing
|
||||
CUFF_LUMA = 0.46 # sleeve cuff bands
|
||||
HEM_LUMA = 0.48 # hem drawcord band
|
||||
STITCH_LUMA = 0.33 # stitch / outline lines
|
||||
TEETH_LUMA = 0.88 # bright zip teeth dashes
|
||||
TEETH_GAP_LUMA = 0.30 # dark zip tape between dashes
|
||||
ALBEDO_NOISE = 0.02 # woven jitter
|
||||
FRONT_EPS_M = 0.004 # front-facing gate on |y| (denim practice)
|
||||
|
||||
# Deep blue-grey cold-weather hue (visible when the region mask is absent;
|
||||
# with the mask bound, identity is carried by luma alone).
|
||||
FABRIC_HUE = np.array([0.62, 0.70, 0.88], dtype=np.float32)
|
||||
|
||||
TEX_SIZE = 1024 # albedo + mask (painted quilt lines need > 512)
|
||||
NOISE_SEED = 2093
|
||||
|
||||
_REF_SHOULDER_X = 0.1919 # average_m anchor (same as base/jacket)
|
||||
|
||||
# Region labels -> mask channels (spec: roll+collar=R, body=G, sleeves=B, trim=A).
|
||||
LBL_G, LBL_R, LBL_B, LBL_A = 0, 1, 2, 3
|
||||
LABEL_NAMES = ["body", "roll", "sleeve", "trim"]
|
||||
LABEL_RGBA_ARR = np.array([
|
||||
(0.0, 1.0, 0.0, 0.0), # G body
|
||||
(1.0, 0.0, 0.0, 0.0), # R hood roll + collar
|
||||
(0.0, 0.0, 1.0, 0.0), # B sleeves
|
||||
(0.0, 0.0, 0.0, 1.0), # A pocket / quilt trim
|
||||
], dtype=np.float32)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Thresholds — base derivation + parka extras from the same armature
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def parka_thresholds(armature):
|
||||
thr = base.derive_thresholds(armature)
|
||||
bones = armature.data.bones
|
||||
|
||||
def bone(name):
|
||||
b = bones.get(name)
|
||||
if b is None:
|
||||
raise RuntimeError(f"landmark bone {name} missing")
|
||||
return b
|
||||
|
||||
ua_l = bone("upperarm_l")
|
||||
ua_r = bone("upperarm_r")
|
||||
neck = bone("neck_01")
|
||||
pelvis = bone("pelvis")
|
||||
spine01 = bone("spine_01")
|
||||
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
scale = shoulder_x / _REF_SHOULDER_X
|
||||
|
||||
# Collar band (taller + wider than the tee) and hood-roll geometry.
|
||||
neck_len = neck.tail_local.z - neck.head_local.z
|
||||
tee_collar_x = thr["collar_x_abs"] # before the parka widening
|
||||
thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
|
||||
thr["collar_x_abs"] = tee_collar_x * COLLAR_X_MULT
|
||||
thr["neck_y"] = neck.head_local.y
|
||||
thr["neck_len"] = neck_len
|
||||
thr["roll_out"] = ROLL_OUT_FRAC * neck_len
|
||||
thr["roll_lift"] = ROLL_LIFT_FRAC * neck_len
|
||||
thr["roll_reach"] = ROLL_REACH_COLLAR_X * tee_collar_x
|
||||
|
||||
# Hip-length hem plane + ring-search allowance (bone-derived, per body).
|
||||
pz, sz = pelvis.head_local.z, spine01.head_local.z
|
||||
thr["hem_z"] = pz + HEM_PELVIS_FRAC * (sz - pz)
|
||||
thr["hem_allow"] = HEM_ALLOW_FRAC * (sz - pz)
|
||||
|
||||
# Wrist cut planes on the lowerarm bones (jacket practice).
|
||||
cut_planes = []
|
||||
lowerarm_len = 0.0
|
||||
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
|
||||
b = bone(bone_name)
|
||||
head_x, tail_x = b.head_local.x, b.tail_local.x
|
||||
cut_planes.append((sign, head_x + CUFF_KEEP_FRAC * (tail_x - head_x)))
|
||||
lowerarm_len = abs(tail_x - head_x)
|
||||
thr["wrist_cut_planes"] = cut_planes
|
||||
thr["cuff_len"] = CUFF_LEN_FRAC * lowerarm_len
|
||||
|
||||
# Storm flap + zip teeth.
|
||||
thr["flap_half"] = shoulder_x * FLAP_HALF_FRAC
|
||||
thr["teeth_half"] = TEETH_HALF_M_REF * scale
|
||||
thr["teeth_period"] = TEETH_PERIOD_M_REF * scale
|
||||
thr["teeth_duty"] = TEETH_DUTY_M_REF * scale
|
||||
|
||||
# Pockets (x now; z after the span is known in finalize_thresholds).
|
||||
thr["pocket_cx"] = POCKET_CX_FRAC * shoulder_x
|
||||
thr["pocket_hw"] = POCKET_HW_FRAC * shoulder_x
|
||||
|
||||
# Left-breast logo patch (replaces the base full-chest box).
|
||||
thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC)
|
||||
spine_lo = spine01.head_local.z
|
||||
span = neck.head_local.z - spine_lo
|
||||
thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span,
|
||||
spine_lo + CHEST_Z_HI_FRAC * span)
|
||||
|
||||
log(f"thresholds: hem z={thr['hem_z']:.3f} collar z>={thr['collar_z_min']:.3f} "
|
||||
f"|x|<{thr['collar_x_abs']:.3f} roll out={thr['roll_out']*1000:.0f}mm "
|
||||
f"reach={thr['roll_reach']:.3f} flap half={thr['flap_half']:.3f} "
|
||||
f"pocket cx={thr['pocket_cx']:.3f} hw={thr['pocket_hw']:.3f} "
|
||||
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
|
||||
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
|
||||
return thr
|
||||
|
||||
|
||||
def finalize_thresholds(shell, thr):
|
||||
"""Mesh-measured extras after cuts/flatten/offset/roll: the garment span
|
||||
(drives quilt/pocket/hem-band proportions) and the cuff anchor from the
|
||||
sleeves' ACTUAL post-cut reach (hoodie practice)."""
|
||||
span = thr["collar_z_min"] - thr["hem_z"]
|
||||
thr["span"] = span
|
||||
thr["quilt_period"] = QUILT_PERIOD_FRAC * span
|
||||
thr["quilt_halfw"] = QUILT_HALFW_FRAC * span
|
||||
thr["hem_band_h"] = HEM_BAND_FRAC * span
|
||||
thr["pocket_z0"] = thr["hem_z"] + POCKET_Z0_FRAC * span
|
||||
thr["pocket_z1"] = thr["hem_z"] + POCKET_Z1_FRAC * span
|
||||
thr["pocket_flap_h"] = POCKET_FLAP_FRAC * (thr["pocket_z1"] - thr["pocket_z0"])
|
||||
wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=0.0)
|
||||
thr["cuff_x0"] = wrist_x - thr["cuff_len"]
|
||||
log(f"finalized: span={span:.3f} quilt period={thr['quilt_period']*1000:.0f}mm "
|
||||
f"pocket z=({thr['pocket_z0']:.3f},{thr['pocket_z1']:.3f}) "
|
||||
f"cuff |x|>={thr['cuff_x0']:.3f} (reach {wrist_x:.3f})")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: weld, cuts, hem flatten, hood roll
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_boundaries(shell):
|
||||
"""Merge coincident segment-boundary verts (denim practice) so the offset
|
||||
can't open cracks at the torso|hips / torso|arm seams. Weights are
|
||||
identical by origin, so skinning is unaffected."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_DIST)
|
||||
merged = before - len(bm.verts)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"welded segment boundaries: {merged} verts merged "
|
||||
f"({before} -> {len(me.vertices)})")
|
||||
|
||||
|
||||
def wrist_cut(shell, thr):
|
||||
"""Delete forearm verts beyond the wrist plane on each side (jacket)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = []
|
||||
for v in bm.verts:
|
||||
for sign, thr_x in thr["wrist_cut_planes"]:
|
||||
if sign > 0 and v.co.x > thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
if sign < 0 and v.co.x < thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"wrist cut removed {len(to_delete)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def hem_cut(shell, thr):
|
||||
"""Trim everything below the hip-length hem plane (removes the seg_hips
|
||||
lower boundary, crotch and leg openings entirely)."""
|
||||
hem_z = thr["hem_z"]
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts if v.co.z < hem_z]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"hem cut at z={hem_z:.3f}: removed {len(doomed)} verts")
|
||||
|
||||
|
||||
def flatten_hem_rim(shell, thr):
|
||||
"""Pull the open hem-ring verts onto the ring's own valley plane (denim
|
||||
flatten practice) — the vert-threshold cut leaves jagged teeth; a clean
|
||||
straight hem is what Solidify(use_rim) then caps. Only boundary verts in
|
||||
the hem band move; the neck and wrist rims sit far above `hem_allow`."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
limit = thr["hem_z"] + thr["hem_allow"]
|
||||
ring = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1: # open boundary
|
||||
for v in e.verts:
|
||||
if v.co.z < limit:
|
||||
ring.add(v.index)
|
||||
if not ring:
|
||||
log("WARNING: no hem-ring verts found — hem left jagged")
|
||||
bm.free()
|
||||
return
|
||||
zs = [bm.verts[i].co.z for i in ring]
|
||||
valley = min(zs)
|
||||
teeth = max(zs) - valley
|
||||
for i in ring:
|
||||
bm.verts[i].co.z = valley
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
thr["hem_z"] = valley # paint anchors reference the clean rim
|
||||
log(f"flattened hem rim: {len(ring)} verts, teeth {teeth:.3f} m "
|
||||
f"-> plane z={valley:.3f}")
|
||||
|
||||
|
||||
def hood_roll(shell, thr):
|
||||
"""Inflate collar-band verts radially away from the neck axis to read as a
|
||||
rolled-down hood (hoodie practice, parka-bulk parameters). Runs after the
|
||||
outward offset and before solidify so the roll gets cloth thickness."""
|
||||
import math
|
||||
z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * thr["neck_len"]
|
||||
reach = thr["roll_reach"]
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
|
||||
candidates = []
|
||||
z_rim = z_start
|
||||
for v in bm.verts:
|
||||
if v.co.z < z_start:
|
||||
continue
|
||||
hd = math.hypot(v.co.x, v.co.y - thr["neck_y"])
|
||||
if hd > reach or hd < 1e-6:
|
||||
continue
|
||||
candidates.append((v, hd))
|
||||
z_rim = max(z_rim, v.co.z)
|
||||
if z_rim <= z_start or not candidates:
|
||||
log("WARNING: no hood-roll candidates found — roll skipped")
|
||||
bm.free()
|
||||
return
|
||||
|
||||
moved = 0
|
||||
for v, hd in candidates:
|
||||
t = (v.co.z - z_start) / (z_rim - z_start)
|
||||
w = max(0.0, min(1.0, t)) ** ROLL_EXPONENT
|
||||
if w <= 0.0:
|
||||
continue
|
||||
dir_h = Vector((v.co.x, v.co.y - thr["neck_y"], 0.0)) / hd
|
||||
backness = 0.5 * (1.0 + dir_h.y * -base.FRONT_Y_SIGN) # +Y = back
|
||||
gain = ROLL_FRONT_GAIN + (ROLL_BACK_GAIN - ROLL_FRONT_GAIN) * backness
|
||||
v.co += dir_h * (thr["roll_out"] * w * gain)
|
||||
v.co.z += thr["roll_lift"] * w
|
||||
moved += 1
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"hood roll: {moved} verts inflated (rim z {z_rim:.3f}, "
|
||||
f"start z {z_start:.3f})")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Feature field — ONE evaluation drives albedo luma AND region label together
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def parka_field(px, py, pz, thr):
|
||||
"""Evaluate the parka feature field at texel 3D positions (numpy arrays).
|
||||
|
||||
Returns (label uint8 array indexing LABEL_RGBA_ARR, luma float32 array).
|
||||
"""
|
||||
n = px.shape[0]
|
||||
label = np.full(n, LBL_G, dtype=np.uint8)
|
||||
luma = np.full(n, BASE_LUMA, dtype=np.float32)
|
||||
ax = np.abs(px)
|
||||
front = py * base.FRONT_Y_SIGN > FRONT_EPS_M
|
||||
ew = thr["quilt_halfw"] # shared edge/stitch line half-width
|
||||
|
||||
on_sleeve = ax >= thr["sleeve_x_abs"]
|
||||
label[on_sleeve] = LBL_B
|
||||
|
||||
# --- zones -------------------------------------------------------------
|
||||
hem = thr["hem_z"]
|
||||
hem_top = hem + thr["hem_band_h"]
|
||||
hem_band = pz <= hem_top
|
||||
cuff = ax >= thr["cuff_x0"]
|
||||
flap = front & (ax <= thr["flap_half"])
|
||||
pocket_dx = np.abs(ax - thr["pocket_cx"])
|
||||
in_pocket_z = (pz >= thr["pocket_z0"]) & (pz <= thr["pocket_z1"])
|
||||
pocket = front & in_pocket_z & (pocket_dx <= thr["pocket_hw"])
|
||||
m = ew * 3.0 # quilt keep-out margin around painted features
|
||||
pocket_pad = front & (pz >= thr["pocket_z0"] - m) & (pz <= thr["pocket_z1"] + m) \
|
||||
& (pocket_dx <= thr["pocket_hw"] + m)
|
||||
x0, x1 = thr["chest_x"]
|
||||
z0, z1 = thr["chest_z"]
|
||||
patch = front & (px >= x0) & (px <= x1) & (pz >= z0) & (pz <= z1)
|
||||
patch_pad = front & (px >= x0 - m) & (px <= x1 + m) \
|
||||
& (pz >= z0 - m) & (pz <= z1 + m)
|
||||
hd = np.hypot(px, py - thr["neck_y"])
|
||||
roll_zmin = thr["collar_z_min"] \
|
||||
- np.where(front, R_Z_DROP_FRONT, R_Z_DROP_BACK) * thr["neck_len"]
|
||||
# Sleeve texels never join the roll (hoodie precedence: sleeve check first)
|
||||
# — without the gate the deltoid caps classify as R on every body.
|
||||
roll = (pz >= roll_zmin) & ~on_sleeve \
|
||||
& (hd <= thr["roll_reach"] + thr["roll_out"] + 0.01)
|
||||
|
||||
# --- quilt channels (constant-z on the body, constant-|x| on sleeves) ---
|
||||
coord = np.where(on_sleeve, ax - thr["sleeve_x_abs"], pz - hem)
|
||||
period = thr["quilt_period"]
|
||||
tph = np.mod(coord, period) / period
|
||||
edge_d = np.minimum(tph, 1.0 - tph) * period
|
||||
quilt_ok = ~(hem_band | cuff | flap | pocket_pad | patch_pad | roll)
|
||||
qline = quilt_ok & (edge_d <= ew)
|
||||
puff = 4.0 * tph * (1.0 - tph)
|
||||
|
||||
# --- luma (paint order = precedence, later writes win) ------------------
|
||||
luma += np.where(quilt_ok, PUFF_AMP * (puff - 0.5) * 2.0, 0.0)
|
||||
luma[qline] = QUILT_LUMA
|
||||
luma[hem_band] = HEM_LUMA
|
||||
hemline = (np.abs(pz - hem_top) <= ew) & ~on_sleeve
|
||||
luma[hemline] = STITCH_LUMA
|
||||
luma[cuff] = CUFF_LUMA
|
||||
cuffline = np.abs(ax - thr["cuff_x0"]) <= ew
|
||||
luma[cuffline] = STITCH_LUMA
|
||||
|
||||
# Big patch pockets: fill, flap, stitch outline.
|
||||
luma[pocket] = POCKET_LUMA
|
||||
flap_z = thr["pocket_z1"] - thr["pocket_flap_h"]
|
||||
luma[pocket & (pz >= flap_z)] = POCKET_FLAP_LUMA
|
||||
p_outline = pocket & (
|
||||
(pocket_dx >= thr["pocket_hw"] - ew)
|
||||
| (pz <= thr["pocket_z0"] + ew)
|
||||
| (pz >= thr["pocket_z1"] - ew)
|
||||
| (np.abs(pz - flap_z) <= ew)
|
||||
)
|
||||
luma[p_outline] = STITCH_LUMA
|
||||
|
||||
# Chest logo patch backing (stays in G so the decal reads on body tint).
|
||||
luma[patch] = PATCH_LUMA
|
||||
patch_edge = patch & (
|
||||
(px <= x0 + ew) | (px >= x1 - ew) | (pz <= z0 + ew) | (pz >= z1 - ew)
|
||||
)
|
||||
luma[patch_edge] = STITCH_LUMA
|
||||
|
||||
# Storm flap + zip teeth (teeth stop under the hood roll).
|
||||
luma[flap] = FLAP_LUMA
|
||||
flap_edge = front & (np.abs(ax - thr["flap_half"]) <= ew * 0.8)
|
||||
luma[flap_edge] = STITCH_LUMA
|
||||
teeth = front & (ax <= thr["teeth_half"]) & ~roll
|
||||
dash = np.mod(pz, thr["teeth_period"]) < thr["teeth_duty"]
|
||||
luma[teeth & dash] = TEETH_LUMA
|
||||
luma[teeth & ~dash] = TEETH_GAP_LUMA
|
||||
|
||||
# Hood roll last: lofted read + a collar seam line right under it.
|
||||
rollseam = (np.abs(pz - roll_zmin) <= ew) & ~on_sleeve \
|
||||
& (hd <= thr["roll_reach"] + thr["roll_out"] + 0.02)
|
||||
luma[rollseam & ~roll] = STITCH_LUMA
|
||||
luma[roll] = ROLL_LUMA
|
||||
|
||||
# --- region labels (same masks; precedence: trim, then roll wins) -------
|
||||
label[qline] = LBL_A
|
||||
label[cuff] = LBL_A
|
||||
label[pocket] = LBL_A
|
||||
label[flap] = LBL_A
|
||||
label[roll] = LBL_R
|
||||
return label, luma
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Combined albedo + mask bake (per-texel, pre-solidify — coverall practice)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _tri_texels(a, b, c, W, H):
|
||||
"""Texels covered by UV triangle (a,b,c) with barycentric weights
|
||||
(coverall rasterizer: returns ys, xs, w0, w1, w2)."""
|
||||
empty = (np.empty(0, int),) * 2 + (np.empty(0, np.float32),) * 3
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return empty
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return empty
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return empty
|
||||
return (
|
||||
ys[inside], xs[inside],
|
||||
w0[inside].astype(np.float32),
|
||||
w1[inside].astype(np.float32),
|
||||
w2[inside].astype(np.float32),
|
||||
)
|
||||
|
||||
|
||||
def bake_albedo_and_mask(shell, thr, out_dir, body, seed):
|
||||
"""One pass over the UV0 faces: albedo and mask from a single shared
|
||||
feature-field evaluation per texel (denim practice). Baked PRE-solidify so
|
||||
there is exactly one face per texel (coverall practice)."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(seed)
|
||||
noise_buf = (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
|
||||
|
||||
alb = np.empty((H, W, 4), dtype=np.float32)
|
||||
alb[:, :, 0:3] = np.clip(
|
||||
(BASE_LUMA + noise_buf)[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0)
|
||||
alb[:, :, 3] = 1.0
|
||||
mask = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask[:, :, 1] = 1.0 # body-green background (bilinear-bleed safe)
|
||||
label_map = np.full((H, W), LBL_G, dtype=np.uint8)
|
||||
covered = np.zeros((H, W), dtype=bool)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask bake")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv for loop in loops]
|
||||
pos = [loop.vert.co for loop in loops]
|
||||
for i in range(1, len(loops) - 1):
|
||||
tri = (0, i, i + 1)
|
||||
ys, xs, w0, w1, w2 = _tri_texels(
|
||||
uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H)
|
||||
if ys.size == 0:
|
||||
continue
|
||||
px3 = w0 * pos[tri[0]].x + w1 * pos[tri[1]].x + w2 * pos[tri[2]].x
|
||||
py3 = w0 * pos[tri[0]].y + w1 * pos[tri[1]].y + w2 * pos[tri[2]].y
|
||||
pz3 = w0 * pos[tri[0]].z + w1 * pos[tri[1]].z + w2 * pos[tri[2]].z
|
||||
lab, lum = parka_field(px3, py3, pz3, thr)
|
||||
lum = np.clip(lum + noise_buf[ys, xs], 0.0, 1.0)
|
||||
alb[ys, xs, 0:3] = np.clip(
|
||||
lum[:, None] * FABRIC_HUE[None, :], 0.0, 1.0)
|
||||
mask[ys, xs] = LABEL_RGBA_ARR[lab]
|
||||
label_map[ys, xs] = lab
|
||||
covered[ys, xs] = True
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
|
||||
total = max(int(covered.sum()), 1)
|
||||
counts = np.bincount(label_map[covered], minlength=len(LABEL_NAMES))
|
||||
log(f"painted {tri_count} UV triangles; region texels: " + " ".join(
|
||||
f"{LABEL_NAMES[i]}={int(c)} ({100.0 * c / total:.1f}%)"
|
||||
for i, c in enumerate(counts)))
|
||||
|
||||
# Alpha floor (coverall lesson): Godot's fix_alpha_border import pass
|
||||
# rewrites RGB of fully-transparent texels — floor A at 2/255 everywhere.
|
||||
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
|
||||
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
img_mask = bpy.data.images.new(f"{GARMENT_ID}_mask_{body}", W, H, alpha=True)
|
||||
# Channel-packed DATA, not imagery: a straight-alpha PNG save would zero
|
||||
# the A (trim) channel (coverall lesson, verified there).
|
||||
img_mask.alpha_mode = 'CHANNEL_PACKED'
|
||||
img_mask.pixels.foreach_set(mask.reshape(-1))
|
||||
img_mask.update()
|
||||
img_mask.filepath_raw = mask_path
|
||||
img_mask.file_format = 'PNG'
|
||||
img_mask.save()
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
|
||||
img_alb = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False)
|
||||
img_alb.pixels.foreach_set(alb.reshape(-1))
|
||||
img_alb.update()
|
||||
return img_alb
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_parka(body_dir, out_dir, body, offset, seed):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = SEGMENTS # build_covered_mesh reads this
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
weld_boundaries(shell)
|
||||
thr = parka_thresholds(armature)
|
||||
|
||||
wrist_cut(shell, thr)
|
||||
hem_cut(shell, thr)
|
||||
flatten_hem_rim(shell, thr)
|
||||
base.offset_outward(shell, offset)
|
||||
hood_roll(shell, thr)
|
||||
finalize_thresholds(shell, thr)
|
||||
|
||||
# UV2 + bake BEFORE solidify (coverall practice: one face per texel).
|
||||
base.author_logo_uv(shell, thr)
|
||||
albedo_img = bake_albedo_and_mask(shell, thr, out_dir, body, seed)
|
||||
|
||||
base.solidify(shell, THICKNESS_M)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
# Shared sidecar name before export: the glTF exporter names the embedded
|
||||
# image after the filepath basename and Godot extracts it as
|
||||
# <glb>_<image>.png — this lands on the tshirt-convention
|
||||
# <body>_base_albedo.png (coverall/jacket practice).
|
||||
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
||||
os.path.join(out_dir, f"{body}_base_albedo.png"))
|
||||
|
||||
|
||||
def main():
|
||||
global HEM_PELVIS_FRAC
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--hem-frac F]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--hem-frac" in argv:
|
||||
HEM_PELVIS_FRAC = float(argv[argv.index("--hem-frac") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"parka per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
|
||||
f"hem-frac {HEM_PELVIS_FRAC}")
|
||||
|
||||
results = []
|
||||
for i, body in enumerate(bodies):
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_parka(body_dir, out_dir, body, offset, seed=NOISE_SEED + i)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Deterministic shared sidecars mirror the reference body (average_m).
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,755 @@
|
||||
"""
|
||||
blender_author_shoes_formal.py (T-1089 wave 2, footwear: shoes_formal)
|
||||
|
||||
Authors FORMAL SHOES (both feet, one garment — the peasant_shoes convention)
|
||||
as per-body offset shells, reusing blender_author_offset_shell.py as a library
|
||||
(scene build, join, offset, solidify, GLB export) and the denim companion's
|
||||
required bottoms/footwear practices (boundary WELD, parked logo UV2).
|
||||
|
||||
What footwear adds, as reusable parameters (sneakers/boots can re-drive this):
|
||||
|
||||
* both-feet build: seg_foot_l + seg_foot_r joined into ONE skinned mesh;
|
||||
the two shells stay disjoint (weights by construction, one draw call).
|
||||
* boundary WELD is load-bearing here, not just hygienic: a raw seg_foot is
|
||||
3-8 mesh islands (upper + a separate flat SOLE PLATE sheet + ankle-band
|
||||
slivers). remove_doubles(0.5 mm) zips them into one shell per foot whose
|
||||
only open boundary is the ankle ring (verified on average_m + child).
|
||||
* ankle TOPLINE cut: the welded ankle ring is jagged weight-threshold teeth
|
||||
(~5 cm on average_m). Verts above the ring's own valley (optionally
|
||||
+--topline-lift) are deleted and the fresh boundary is flattened UP onto
|
||||
the topline plane — a clean horizontal low-profile shoe opening. Guard
|
||||
rails clamp the topline to [0.70, 0.95] x the foot-bone head height.
|
||||
The rim ring is then RELAXED in XY (neighbour averaging, z pinned) so the
|
||||
throat reads as a smooth loafer opening instead of a jagged U.
|
||||
* TOE-BOX smoothing: the skin mesh has individual toe bumps; offsetting
|
||||
them verbatim yields a five-finger foot-glove. Forefoot verts are
|
||||
Laplacian-smoothed in feathered bands (mild at the metatarsal, strong at
|
||||
the toes) into one smooth formal toe box, then given a small extra
|
||||
standoff (--toe-extra) to buy back the clearance smoothing costs over
|
||||
the toe bumps. Band reps are deliberately MODERATE: smoothing migrates
|
||||
shell verts away from the skin they are weighted like, and under toe
|
||||
flex (Walk heel-strike / Sprint push-off) the migration error scales
|
||||
with sin(flex angle) — first authoring pass used (2,4,8) reps and the
|
||||
animated toes visibly overtook the toe box.
|
||||
* CLEARANCE ENFORCEMENT (the fix that guards all of the above): a BVH
|
||||
snapshot of the post-cut, PRE-smoothing skin surface; after offset +
|
||||
toe-extra, every shell vert closer than the shell offset to the skin
|
||||
(signed, along the skin normal) is pushed out to exactly that standoff.
|
||||
Guaranteed rest-pose clearance by construction, no matter how far the
|
||||
toe smoothing migrated verts.
|
||||
* SOLE as an offset-shell param extension: pre-offset, downward-facing
|
||||
verts (normal.z < -0.5) are recorded; post-offset they are flattened onto
|
||||
a plane so the finished exterior bottom sits --sole-mm (default 8 mm)
|
||||
below the skin's lowest point once Solidify adds cloth thickness. A final
|
||||
clamp keeps every vert on/above the plane — thin flat sole.
|
||||
* UV0 NORMALIZATION: feet occupy a tiny corner of the body texture atlas;
|
||||
since BOTH the painted albedo and the region mask are authored here on
|
||||
UV0 (skin textures are dropped), the used UV bbox is rescaled to fill
|
||||
[0,1] — ~10x texel density for the painted seams at no cost. Mirrored
|
||||
L/R UV islands may overlap; all painted features are x-symmetric, so
|
||||
overlap is harmless by construction.
|
||||
* texel-level feature painting (denim's albedo+mask-from-one-field idea):
|
||||
- albedo: painted TOE CAP LINE across the vamp, sole-edge welt stitch,
|
||||
topline edge stitch, heel counter seam — flat toon-friendly,
|
||||
identity carried by luminance (the toon_garment shader tints
|
||||
by luma, so lines survive a black default tint).
|
||||
- mask: sole -> R, upper -> G, toe cap -> B (spec regions).
|
||||
* parked logo UV2 (shoes are not logo-capable; shader samples UV2 anyway).
|
||||
|
||||
All parameters derive PER BODY from that body's own bone landmarks (foot_l /
|
||||
ball_l) and measured mesh extents (toe/heel y, skin bottom z, per-foot ankle
|
||||
ring valley), scaled by foot length against the average_m reference — the
|
||||
same proportional-ratio philosophy as base.derive_thresholds. Per-body mode
|
||||
only (offset shells author per body, Q-060).
|
||||
|
||||
Usage (shoes_formal reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_shoes_formal.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/shoes_formal \
|
||||
[--bodies average_m,child,...] [--offset 0.006] [--sole-mm 0.008] \
|
||||
[--topline-lift 0.0] [--cap-frac 0.35] [--base-rgb r,g,b] \
|
||||
[--sole-rgb r,g,b] [--plain] [--no-uv-normalize]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Coverage note (the peasant_shoes convention): footwear ships hides: [] — the
|
||||
foot skin stays VISIBLE at runtime because the shoe throat legitimately shows
|
||||
the instep (hiding the feet would open a see-through hole there). The
|
||||
chromakey QA still gates: its config passes an explicit "covers":
|
||||
["foot_l","foot_r"] so the feet are keyed even though coverage.json hides
|
||||
nothing.
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module + the denim companion (shared utilities)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(mod_name, fname):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
mod_name, os.path.join(_HERE, fname))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load("offset_shell_base", "blender_author_offset_shell.py")
|
||||
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
|
||||
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y; toes point -Y
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (all reusable across the footwear family)
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = ["seg_foot_l", "seg_foot_r"]
|
||||
|
||||
SHELL_OFFSET_M = 0.006 # slim low-profile standoff (weights exact per body)
|
||||
SOLE_TOTAL_M = 0.008 # finished exterior sole drop below the skin bottom
|
||||
TOPLINE_LIFT_M = 0.0 # extra height above the ankle-ring valley
|
||||
TOPLINE_FLOOR_FRAC = 0.70 # topline >= this frac of foot-bone head z
|
||||
TOPLINE_CEIL_FRAC = 0.95 # topline <= this frac of foot-bone head z
|
||||
CAP_BALL_FRAC = 0.35 # toe-cap line along the ball bone (head -> tail)
|
||||
SOLE_BAND_M = 0.010 # sole side band height on average_m (R region)
|
||||
SEAM_W_M = 0.0030 # painted seam width on average_m
|
||||
SEAM_W_MIN_M = 0.0016 # floor so child seams don't alias away
|
||||
HEEL_SEAM_FRAC = 0.16 # heel counter seam, fraction of foot len from heel
|
||||
MIN_ISLAND_VERTS = 10 # post-cut sliver cleanup threshold
|
||||
TOE_SMOOTH_REPS = (2, 3, 4) # feathered smoothing bands, metatarsal -> toes
|
||||
# (moderate: heavy reps migrate verts off their
|
||||
# weight-source skin and animated toes poke out)
|
||||
TOE_EXTRA_OFFSET_M = 0.005 # extra forefoot standoff after smoothing
|
||||
RIM_RELAX_PASSES = 2 # XY neighbour-average passes on the topline ring
|
||||
TEX_SIZE = 1024
|
||||
UV_NORMALIZE = True # rescale used UV bbox to fill [0,1]
|
||||
PLAIN = False # --plain: skip painted stitch lines
|
||||
|
||||
# Formal leather style (luma carries the detail; runtime tints recolor).
|
||||
LEATHER_RGB = (0.320, 0.315, 0.330) # upper mid-grey leather
|
||||
SOLE_RGB = (0.235, 0.230, 0.240) # sole band slightly darker
|
||||
STITCH_RGB = (0.560, 0.550, 0.570) # painted seam thread (lighter luma)
|
||||
ALBEDO_NOISE = 0.015
|
||||
NOISE_SEED = 3089
|
||||
|
||||
# Reference proportions (average_m) the fractions were calibrated against.
|
||||
_REF_FOOT_LEN = 0.2704 # heel y (+0.1374) - toe y (-0.1330)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FootLandmarks:
|
||||
"""Cut/mask/paint parameters derived from one body's bones + mesh."""
|
||||
|
||||
def __init__(self, armature, mesh):
|
||||
bones = armature.data.bones
|
||||
foot = bones.get("foot_l")
|
||||
ball = bones.get("ball_l")
|
||||
if foot is None or ball is None:
|
||||
raise RuntimeError("foot_l/ball_l missing — not the 65-bone rig?")
|
||||
self.ankle_z = foot.head_local.z
|
||||
ys = [v.co.y for v in mesh.vertices]
|
||||
zs = [v.co.z for v in mesh.vertices]
|
||||
self.toe_y = min(ys) # toes point -Y (FRONT_Y_SIGN)
|
||||
self.heel_y = max(ys)
|
||||
self.skin_min_z = min(zs)
|
||||
self.foot_len = self.heel_y - self.toe_y
|
||||
self.s = self.foot_len / _REF_FOOT_LEN
|
||||
# Toe-cap line sits along the ball bone (metatarsal -> toes).
|
||||
self.ball_head_y = ball.head_local.y
|
||||
self.ball_tail_y = ball.tail_local.y
|
||||
self.cap_y = self.ball_head_y + CAP_BALL_FRAC * (
|
||||
self.ball_tail_y - self.ball_head_y)
|
||||
self.heel_seam_y = self.heel_y - HEEL_SEAM_FRAC * self.foot_len
|
||||
# Geometry planes: Solidify adds CLOTH_THICKNESS_M outward (down at
|
||||
# the sole), so the pre-solidify flatten plane sits thickness higher.
|
||||
self.sole_plane = (self.skin_min_z - SOLE_TOTAL_M
|
||||
+ base.CLOTH_THICKNESS_M)
|
||||
self.sole_top = self.sole_plane + SOLE_BAND_M * self.s
|
||||
self.seam_w = max(SEAM_W_M * self.s, SEAM_W_MIN_M)
|
||||
self.topline = {} # per foot side ('L'/'R'), set by ankle cut
|
||||
log(f"landmarks: ankle_z={self.ankle_z:.4f} foot_len={self.foot_len:.4f} "
|
||||
f"(s={self.s:.3f}) cap_y={self.cap_y:.4f} "
|
||||
f"heel_seam_y={self.heel_seam_y:.4f} skin_min_z={self.skin_min_z:.4f} "
|
||||
f"sole_plane={self.sole_plane:.4f} sole_top={self.sole_top:.4f} "
|
||||
f"seam_w={self.seam_w * 1000:.1f}mm")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def recalc_normals(shell):
|
||||
"""Consistent outward face normals pre-offset (the raw foot carries a
|
||||
separately-authored sole plate whose orientation is not guaranteed)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log("recalculated outward face normals")
|
||||
|
||||
|
||||
def _side(x):
|
||||
return "L" if x >= 0.0 else "R"
|
||||
|
||||
|
||||
def ankle_cut_and_flatten(shell, lm, lift):
|
||||
"""Per foot: topline = clamp(ankle-ring valley + lift); delete verts above
|
||||
it; drop disconnected slivers; flatten the fresh boundary UP onto the
|
||||
plane. Lifting (not lowering) is safe for footwear: the rim sits a full
|
||||
shell-offset OUTSIDE the skin, so raising it only deepens the overlap
|
||||
with the (visible) ankle skin — cloth over skin, never a gap."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
|
||||
# 1. Per-foot ankle-ring valley (the welded shell's only open boundary).
|
||||
ring_z = {"L": [], "R": []}
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
for v in e.verts:
|
||||
ring_z[_side(v.co.x)].append(v.co.z)
|
||||
floor_z = TOPLINE_FLOOR_FRAC * lm.ankle_z
|
||||
ceil_z = TOPLINE_CEIL_FRAC * lm.ankle_z
|
||||
for side in ("L", "R"):
|
||||
if not ring_z[side]:
|
||||
raise RuntimeError(f"no ankle boundary ring on side {side}")
|
||||
valley = min(ring_z[side])
|
||||
lm.topline[side] = min(max(valley + lift, floor_z), ceil_z)
|
||||
log(f"topline {side}: valley={valley:.4f} -> {lm.topline[side]:.4f} "
|
||||
f"(guards [{floor_z:.4f}, {ceil_z:.4f}], ring teeth "
|
||||
f"{max(ring_z[side]) - valley:.4f} m)")
|
||||
|
||||
# 2. Cut above the topline.
|
||||
doomed = [v for v in bm.verts if v.co.z > lm.topline[_side(v.co.x)]]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
log(f"ankle cut removed {len(doomed)} verts")
|
||||
|
||||
# 3. Sliver cleanup: islands the cut disconnected.
|
||||
bm.verts.ensure_lookup_table()
|
||||
seen = set()
|
||||
doomed_isl = []
|
||||
for v in bm.verts:
|
||||
if v.index in seen:
|
||||
continue
|
||||
stack, isl = [v], set()
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
if cur.index in isl:
|
||||
continue
|
||||
isl.add(cur.index)
|
||||
for e in cur.link_edges:
|
||||
o = e.other_vert(cur)
|
||||
if o.index not in isl:
|
||||
stack.append(o)
|
||||
seen |= isl
|
||||
if len(isl) < MIN_ISLAND_VERTS:
|
||||
doomed_isl.extend(isl)
|
||||
if doomed_isl:
|
||||
bm.verts.ensure_lookup_table()
|
||||
bmesh.ops.delete(bm, geom=[bm.verts[i] for i in doomed_isl],
|
||||
context='VERTS')
|
||||
log(f"removed {len(doomed_isl)} sliver-island verts")
|
||||
|
||||
# 4. Flatten the fresh jagged boundary UP onto the topline plane.
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
lifted = 0
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
for v in e.verts:
|
||||
tl = lm.topline[_side(v.co.x)]
|
||||
if v.co.z != tl:
|
||||
v.co.z = tl
|
||||
lifted += 1
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened ankle rims: {lifted} boundary verts -> topline planes")
|
||||
|
||||
|
||||
def relax_rim(shell, lm, passes):
|
||||
"""XY neighbour-averaging over the topline boundary ring (z pinned to the
|
||||
topline) so the throat opening reads smooth. Mild by design: the ring
|
||||
sits a full shell-offset outside the skin, and 2 half-weight passes stay
|
||||
well inside that budget."""
|
||||
if passes <= 0:
|
||||
return
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
adj = {}
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
a, b = e.verts
|
||||
adj.setdefault(a.index, []).append(b.index)
|
||||
adj.setdefault(b.index, []).append(a.index)
|
||||
for _ in range(passes):
|
||||
new_xy = {}
|
||||
for i, nbrs in adj.items():
|
||||
if not nbrs:
|
||||
continue
|
||||
ax = sum(bm.verts[j].co.x for j in nbrs) / len(nbrs)
|
||||
ay = sum(bm.verts[j].co.y for j in nbrs) / len(nbrs)
|
||||
v = bm.verts[i]
|
||||
new_xy[i] = (0.5 * v.co.x + 0.5 * ax, 0.5 * v.co.y + 0.5 * ay)
|
||||
for i, (x, y) in new_xy.items():
|
||||
bm.verts[i].co.x = x
|
||||
bm.verts[i].co.y = y
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"relaxed topline rim: {len(adj)} verts, {passes} XY passes")
|
||||
|
||||
|
||||
def smooth_toe_box(shell, lm):
|
||||
"""Feathered Laplacian smoothing over the forefoot so the individual toe
|
||||
bumps merge into one formal toe box. Bands (mild -> strong toward the
|
||||
toes) avoid a crease at the smoothing frontier."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
margin = 0.012 * lm.s
|
||||
bands = [
|
||||
(lm.ball_head_y + margin, TOE_SMOOTH_REPS[0]),
|
||||
(lm.ball_head_y, TOE_SMOOTH_REPS[1]),
|
||||
((lm.ball_head_y + lm.ball_tail_y) * 0.5, TOE_SMOOTH_REPS[2]),
|
||||
]
|
||||
total = 0
|
||||
for y_max, reps in bands:
|
||||
verts = [v for v in bm.verts if v.co.y < y_max]
|
||||
if not verts:
|
||||
continue
|
||||
for _ in range(reps):
|
||||
bmesh.ops.smooth_vert(bm, verts=verts, factor=0.5,
|
||||
use_axis_x=True, use_axis_y=True,
|
||||
use_axis_z=True)
|
||||
total += len(verts) * reps
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"smoothed toe box: bands at y<{[f'{b:.3f}' for b, _ in bands]} "
|
||||
f"({total} vert-passes)")
|
||||
|
||||
|
||||
def toe_extra_offset(shell, lm, extra):
|
||||
"""Extra forefoot standoff (feathered along y) to buy back the clearance
|
||||
the toe smoothing costs over the toe bumps."""
|
||||
if extra <= 0.0:
|
||||
return
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.normal_update()
|
||||
margin = 0.012 * lm.s
|
||||
y0 = lm.ball_head_y + margin # feather start (0 extra)
|
||||
y1 = lm.ball_tail_y # full extra from the ball line on
|
||||
n = 0
|
||||
for v in bm.verts:
|
||||
if v.co.y < y0:
|
||||
t = min((y0 - v.co.y) / max(y0 - y1, 1e-6), 1.0)
|
||||
v.co += v.normal * (extra * t)
|
||||
n += 1
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"toe extra offset: {n} verts, +{extra * 1000:.1f} mm feathered")
|
||||
|
||||
|
||||
def snapshot_skin_bvh(shell):
|
||||
"""BVH of the current (post-cut, pre-smoothing) skin surface — at this
|
||||
stage the shell verts still ARE the skin verts, so this is the reference
|
||||
every later deformation is measured against."""
|
||||
import mathutils.bvhtree
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bvh = mathutils.bvhtree.BVHTree.FromBMesh(bm)
|
||||
bm.free()
|
||||
log("snapshotted skin surface BVH (clearance reference)")
|
||||
return bvh
|
||||
|
||||
|
||||
def enforce_clearance(shell, skin_bvh, min_clearance):
|
||||
"""Push any shell vert closer than `min_clearance` to the skin snapshot
|
||||
out to exactly that standoff (along the skin normal). This is what makes
|
||||
the toe smoothing safe: Laplacian migration can leave the toe box inside
|
||||
the toe bumps; here every vert gets its rest-pose clearance back by
|
||||
construction."""
|
||||
me = shell.data
|
||||
pushed = 0
|
||||
worst = 0.0
|
||||
for v in me.vertices:
|
||||
hit = skin_bvh.find_nearest(v.co)
|
||||
if hit is None or hit[0] is None:
|
||||
continue
|
||||
location, normal, _idx, _dist = hit
|
||||
signed = (v.co - location).dot(normal)
|
||||
if signed < min_clearance:
|
||||
v.co = location + normal * min_clearance
|
||||
pushed += 1
|
||||
worst = max(worst, min_clearance - signed)
|
||||
me.update()
|
||||
log(f"enforced clearance {min_clearance * 1000:.1f} mm: pushed {pushed} "
|
||||
f"verts (worst deficit {worst * 1000:.1f} mm)")
|
||||
|
||||
|
||||
def classify_sole_verts(shell):
|
||||
"""Indices of downward-facing verts (the foot underside), pre-offset."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
idx = [v.index for v in bm.verts if v.normal.z < -0.5]
|
||||
bm.free()
|
||||
log(f"classified {idx and len(idx) or 0} sole (downward-normal) verts")
|
||||
return idx
|
||||
|
||||
|
||||
def flatten_sole(shell, sole_idx, sole_plane):
|
||||
"""Post-offset: pull the underside onto the flat sole plane, then clamp
|
||||
everything on/above it (toe rounding can dip below after the offset)."""
|
||||
me = shell.data
|
||||
for i in sole_idx:
|
||||
me.vertices[i].co.z = sole_plane
|
||||
clamped = 0
|
||||
for v in me.vertices:
|
||||
if v.co.z < sole_plane:
|
||||
v.co.z = sole_plane
|
||||
clamped += 1
|
||||
me.update()
|
||||
log(f"flattened sole: {len(sole_idx)} verts -> z={sole_plane:.4f} "
|
||||
f"(+{clamped} clamped)")
|
||||
|
||||
|
||||
def clamp_topline_residue(shell, lm):
|
||||
"""Post-solidify safety clamp: rim-adjacent verts the Solidify pushed
|
||||
above the topline get squashed back onto it (denim waist pattern)."""
|
||||
me = shell.data
|
||||
n = 0
|
||||
for v in me.vertices:
|
||||
tl = lm.topline[_side(v.co.x)]
|
||||
if v.co.z > tl:
|
||||
v.co.z = tl
|
||||
n += 1
|
||||
me.update()
|
||||
if n:
|
||||
log(f"clamped {n} residual topline verts")
|
||||
|
||||
|
||||
def normalize_uv0(shell):
|
||||
"""Rescale the used UV0 bbox to fill [0,1] (uniform scale, aspect kept).
|
||||
Feet use a tiny corner of the body atlas; both the albedo and the mask
|
||||
are authored here on UV0, so reclaiming the space is free texel density."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
uvl = bm.loops.layers.uv[0]
|
||||
us, vs = [], []
|
||||
for f in bm.faces:
|
||||
for lo in f.loops:
|
||||
us.append(lo[uvl].uv.x)
|
||||
vs.append(lo[uvl].uv.y)
|
||||
u0, u1, v0, v1 = min(us), max(us), min(vs), max(vs)
|
||||
span = max(u1 - u0, v1 - v0)
|
||||
if span < 1e-6:
|
||||
bm.free()
|
||||
log("WARNING: degenerate UV bbox — normalization skipped")
|
||||
return
|
||||
scale = 0.96 / span
|
||||
for f in bm.faces:
|
||||
for lo in f.loops:
|
||||
uv = lo[uvl].uv
|
||||
uv.x = 0.02 + (uv.x - u0) * scale
|
||||
uv.y = 0.02 + (uv.y - v0) * scale
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"normalized UV0: bbox ({u0:.3f},{v0:.3f})..({u1:.3f},{v1:.3f}) "
|
||||
f"-> [0.02,0.98] (x{scale:.1f} density)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions.
|
||||
|
||||
Regions (spec): sole -> R, upper -> G, toe cap -> B.
|
||||
Painted lines (albedo only): toe cap line, sole welt stitch, topline edge
|
||||
stitch, heel counter seam.
|
||||
"""
|
||||
n = px.shape[0]
|
||||
tl = np.where(px >= 0.0, lm.topline.get("L", 1.0), lm.topline.get("R", 1.0))
|
||||
w2 = lm.seam_w * 0.5
|
||||
|
||||
sole = pz < lm.sole_top
|
||||
cap = (~sole) & (py <= lm.cap_y)
|
||||
|
||||
# --- albedo -------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = LEATHER_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
for c in range(3):
|
||||
alb[sole, c] = SOLE_RGB[c] + noise[sole]
|
||||
|
||||
if not PLAIN:
|
||||
capline = (~sole) & (np.abs(py - lm.cap_y) < w2)
|
||||
welt = np.abs(pz - lm.sole_top) < w2
|
||||
topstitch = (~sole) & (np.abs(pz - (tl - 3.0 * w2)) < w2)
|
||||
heelseam = (~sole) & (py > 0.0) \
|
||||
& (np.abs(py - lm.heel_seam_y) < w2)
|
||||
thread = capline | welt | topstitch | heelseam
|
||||
for c in range(3):
|
||||
alb[thread, c] = STITCH_RGB[c]
|
||||
|
||||
# --- region mask: sole R / upper G / toe cap B ---------------------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
mask[sole, 0] = 1.0
|
||||
mask[cap, 2] = 1.0
|
||||
mask[~(sole | cap), 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the footwear field, write albedo + mask together."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = LEATHER_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = upper green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"shoes_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"shoes_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
|
||||
# Re-save the albedo under the SHARED sidecar name and leave the image
|
||||
# datablock pointing there: the glTF exporter derives the embedded image
|
||||
# name from the filepath basename, so the GLB carries "base_albedo" and
|
||||
# Godot's extract-on-import lands exactly on <body>_base_albedo.png (the
|
||||
# wave-1 convention) instead of doubling to <body>_<body>_base_albedo.png.
|
||||
# The pixels at base_albedo.png are THIS body's during its export; main()
|
||||
# restores the reference body's copy after the loop.
|
||||
shared_path = os.path.join(os.path.dirname(albedo_path), "base_albedo.png")
|
||||
albedo_img.filepath_raw = shared_path
|
||||
albedo_img.save()
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_shoes(body_dir, out_dir, body, offset, topline_lift):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell) # zips upper + sole plate + ankle slivers
|
||||
recalc_normals(shell)
|
||||
|
||||
lm = FootLandmarks(armature, shell.data)
|
||||
ankle_cut_and_flatten(shell, lm, topline_lift)
|
||||
relax_rim(shell, lm, RIM_RELAX_PASSES)
|
||||
skin_bvh = snapshot_skin_bvh(shell) # BEFORE smoothing moves anything
|
||||
smooth_toe_box(shell, lm)
|
||||
|
||||
sole_idx = classify_sole_verts(shell)
|
||||
base.offset_outward(shell, offset)
|
||||
toe_extra_offset(shell, lm, TOE_EXTRA_OFFSET_M)
|
||||
enforce_clearance(shell, skin_bvh, offset)
|
||||
flatten_sole(shell, sole_idx, lm.sole_plane)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
clamp_topline_residue(shell, lm)
|
||||
|
||||
if UV_NORMALIZE:
|
||||
normalize_uv0(shell)
|
||||
denim.author_parked_uv2(shell) # shoes are not logo-capable
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global CAP_BALL_FRAC, LEATHER_RGB, SOLE_RGB, SOLE_TOTAL_M, PLAIN
|
||||
global UV_NORMALIZE
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--sole-mm M] [--topline-lift M] [--cap-frac F] "
|
||||
"[--base-rgb r,g,b] [--sole-rgb r,g,b] [--plain] "
|
||||
"[--no-uv-normalize]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = SHELL_OFFSET_M
|
||||
topline_lift = TOPLINE_LIFT_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--sole-mm" in argv:
|
||||
SOLE_TOTAL_M = float(argv[argv.index("--sole-mm") + 1])
|
||||
if "--topline-lift" in argv:
|
||||
topline_lift = float(argv[argv.index("--topline-lift") + 1])
|
||||
if "--cap-frac" in argv:
|
||||
CAP_BALL_FRAC = float(argv[argv.index("--cap-frac") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
LEATHER_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
if "--sole-rgb" in argv:
|
||||
SOLE_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--sole-rgb") + 1].split(","))
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
if "--no-uv-normalize" in argv:
|
||||
UV_NORMALIZE = False
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"shoes per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{offset * 1000:.0f} mm, sole {SOLE_TOTAL_M * 1000:.0f} mm, "
|
||||
f"plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_shoes(body_dir, out_dir, body, offset, topline_lift)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,676 @@
|
||||
"""
|
||||
blender_author_slides.py (T-1089 wave 2, slides — open swim footwear)
|
||||
|
||||
Authors SLIDES (open sandal: flat sole slab + one broad strap band across the
|
||||
midfoot) as per-body offset shells, reusing blender_author_offset_shell.py as
|
||||
a library (scene build/join, offset, solidify, GLB export) and the denim
|
||||
companion's required bottoms/footwear practices (boundary WELD of coincident
|
||||
segment-seam verts before offsetting; the parked logo UV2 layer).
|
||||
|
||||
First offset-shell FOOTWEAR: follows the peasant_shoes both-feet convention
|
||||
(one garment covers seg_foot_l + seg_foot_r), and adds what feet need that no
|
||||
torso/leg companion provides, as reusable parameters:
|
||||
|
||||
* STRAP band cut — the foot shell is cut down to just the midfoot band
|
||||
between two Y planes derived from that body's own foot bones
|
||||
(fractions of the ball_l/r head -> foot_l/r head span, identical 65-bone
|
||||
rig on all 11 bodies). Probe evidence: the seg_foot ankle rim (the
|
||||
weight-threshold splitter's jagged boundary, 3.3-8.3 cm teeth post-weld)
|
||||
stays ABOVE y-fraction ~0.62 of that span on every body, so a band cut at
|
||||
<= 0.58 removes the entire jagged rim by construction. The band keeps the
|
||||
full cross-section ring (including under-foot skin) so the strap-to-sole
|
||||
join is gap-free by construction (the coverall waist-join pattern); the
|
||||
hidden under-foot part is swallowed by the sole slab.
|
||||
* clean strap rims by PLANE BISECT — the wave-1 delete-then-flatten rim
|
||||
practice is too crude for the foot's large instep triangles (it notches
|
||||
the strap crest); the band is instead cut with two exact bisect planes,
|
||||
which yields the same clean-plane end state the denim flatten was after,
|
||||
by construction (bmesh interpolates weights/UVs on the new edge verts).
|
||||
A rim check verifies every open-edge vert sits on a cut plane.
|
||||
* SOLE slab construction — per foot, the 2D convex hull (monotone chain) of
|
||||
that foot's full footprint, expanded radially by a margin, extruded into
|
||||
a prism from below the skin's lowest point to just above it (the foot
|
||||
visually rests IN the footbed). Sole verts receive skin weights by
|
||||
nearest-vertex transfer from the pre-cut foot snapshot, so the sole bends
|
||||
with the foot/ball bones during Walk/Sprint toe-off. A FLEX CREASE ring
|
||||
is bisected into each prism at the ball-joint line so the slab hinges
|
||||
where the foot hinges (QA evidence: without it, toe skin dips through
|
||||
the linearly-interpolated top face in deep-crouch toe-off).
|
||||
* under-sole clamp — strap ring verts that offset/solidify pushed below the
|
||||
sole interior are clamped onto a plane inside the slab (invisible), so the
|
||||
strap never pokes out of the sole bottom.
|
||||
* deterministic region UVs — the garment is fully re-UV'd (the foot's body
|
||||
atlas layout is useless for garment paint): strap faces pack into the left
|
||||
half of UV space, sole faces into the right half, each planar-projected by
|
||||
dominant normal axis into three stacked tiles. Faces of one region may
|
||||
overlap in UV (they share one flat colour), but strap and sole texels are
|
||||
DISJOINT by construction — no cross-region contamination, no operator
|
||||
(bpy.ops.uv.*) dependency in background mode.
|
||||
* albedo + region mask painted together per face: sole -> R (tint_0),
|
||||
strap -> G (tint_1); bright default albedo (spec: swim family, bright).
|
||||
Painted texels are dilated outward so bilinear/mip bleed at tile edges
|
||||
never lands on an untinted texel.
|
||||
|
||||
Usage (per-body only — offset shells author per body, Q-060):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_slides.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/slides \
|
||||
[--bodies average_m,child,...] [--offset 0.005] \
|
||||
[--strap-y0 0.10] [--strap-y1 0.58] \
|
||||
[--sole-margin 0.007] [--sole-embed 0.009] [--sole-drop 0.010] \
|
||||
[--strap-rgb 0.93,0.35,0.20] [--sole-rgb 0.82,0.83,0.85] [--seed N]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module + the denim companion (shared machinery)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(name, fname):
|
||||
spec = importlib.util.spec_from_file_location(name, os.path.join(_HERE, fname))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load("offset_shell_base", "blender_author_offset_shell.py")
|
||||
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
|
||||
|
||||
log = base.log
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = ["seg_foot_l", "seg_foot_r"]
|
||||
|
||||
STRAP_OFFSET_M = 0.005 # strap standoff from skin (slides hug the foot)
|
||||
STRAP_THICKNESS_M = 0.005 # Solidify thickness (chunky foam strap)
|
||||
|
||||
# Strap band window as fractions of the ball-head -> foot(ankle)-head Y span,
|
||||
# measured from the ball. Probe: the jagged seg_foot ankle rim starts at
|
||||
# fraction ~0.62 on every body, so y1 <= 0.58 excludes it by construction.
|
||||
STRAP_Y0_FRAC = 0.10
|
||||
STRAP_Y1_FRAC = 0.58
|
||||
|
||||
# Sole slab (metres on average_m; scaled by each body's foot-length ratio).
|
||||
SOLE_MARGIN_M = 0.007 # radial footprint expansion beyond the skin hull
|
||||
SOLE_EMBED_M = 0.009 # slab top above the foot's lowest skin point
|
||||
SOLE_DROP_M = 0.010 # slab bottom below the foot's lowest skin point
|
||||
CLAMP_INSET_M = 0.003 # strap under-foot verts clamped this far above
|
||||
# the slab bottom (kept inside the sole)
|
||||
|
||||
# Bright default (spec: swim family). Texture carries identity; the runtime
|
||||
# toon_garment.gdshader recolors per region via luma, so these tones ARE the
|
||||
# default look and default_tints should match them.
|
||||
STRAP_RGB = (0.93, 0.35, 0.20) # bright coral strap (G region, tint_1)
|
||||
SOLE_RGB = (0.82, 0.83, 0.85) # off-white foam sole (R region, tint_0)
|
||||
ALBEDO_NOISE = 0.018 # +/- jitter, subtle foam/EVA feel
|
||||
NOISE_SEED = 3089
|
||||
|
||||
TEX_SIZE = 512 # slides are small; 512 is plenty
|
||||
DILATE_PX = 6 # painted-texel dilation into the background
|
||||
|
||||
_REF_FOOT_SPAN = 0.2211 # average_m: foot_l head y (0.0875) - ball_l tail y (-0.1336)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FootLandmarks:
|
||||
"""Per-side cut/sole parameters from one body's own foot bones + mesh."""
|
||||
|
||||
def __init__(self, armature):
|
||||
bones = armature.data.bones
|
||||
self.sides = {}
|
||||
spans = []
|
||||
for side, sign in (("l", +1), ("r", -1)):
|
||||
foot = bones.get(f"foot_{side}")
|
||||
ball = bones.get(f"ball_{side}")
|
||||
if foot is None or ball is None:
|
||||
raise RuntimeError(
|
||||
f"foot_{side}/ball_{side} missing — not the 65-bone rig?")
|
||||
ankle_y = foot.head_local.y
|
||||
ball_y = ball.head_local.y
|
||||
toe_y = ball.tail_local.y
|
||||
span = ankle_y - toe_y
|
||||
spans.append(span)
|
||||
self.sides[sign] = {
|
||||
"y0": ball_y + STRAP_Y0_FRAC * (ankle_y - ball_y),
|
||||
"y1": ball_y + STRAP_Y1_FRAC * (ankle_y - ball_y),
|
||||
"ball_y": ball_y,
|
||||
}
|
||||
self.scale = (sum(spans) / len(spans)) / _REF_FOOT_SPAN
|
||||
for sign in (+1, -1):
|
||||
s = self.sides[sign]
|
||||
log(f"landmarks side {'L' if sign > 0 else 'R'}: "
|
||||
f"strap y[{s['y0']:.4f},{s['y1']:.4f}]")
|
||||
log(f"foot scale vs average_m: {self.scale:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: snapshot, strap cut, rim flatten, sole build
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def snapshot_skin(shell):
|
||||
"""Record post-weld skin verts: positions + per-vertex group weights.
|
||||
|
||||
Used later for the sole's nearest-vertex weight transfer, after the strap
|
||||
cut has thrown most of the foot away.
|
||||
"""
|
||||
me = shell.data
|
||||
pos = np.array([(v.co.x, v.co.y, v.co.z) for v in me.vertices],
|
||||
dtype=np.float64)
|
||||
weights = [
|
||||
[(g.group, g.weight) for g in v.groups if g.weight > 0.0]
|
||||
for v in me.vertices
|
||||
]
|
||||
return pos, weights
|
||||
|
||||
|
||||
def strap_cut(shell, lm):
|
||||
"""Cut the foot shell down to the strap band with two exact plane bisects.
|
||||
|
||||
The wave-1 bottoms practice (delete-then-flatten) is too crude here: the
|
||||
foot mesh's instep triangles are large relative to the 5-7 cm band, so
|
||||
vertex deletion notches the strap crest and rim-snapping folds it. Bisect
|
||||
planes give clean straight rims BY CONSTRUCTION (bmesh interpolates
|
||||
weights/UVs on the new edge verts), which is the same end state the denim
|
||||
flatten was after. The rig's feet are mirrored, so one Y window (side
|
||||
average) serves both feet.
|
||||
"""
|
||||
y0 = (lm.sides[+1]["y0"] + lm.sides[-1]["y0"]) / 2.0
|
||||
y1 = (lm.sides[+1]["y1"] + lm.sides[-1]["y1"]) / 2.0
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.bisect_plane(
|
||||
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
|
||||
plane_co=(0.0, y0, 0.0), plane_no=(0.0, 1.0, 0.0),
|
||||
clear_inner=True) # drop y < y0 (toe side)
|
||||
bmesh.ops.bisect_plane(
|
||||
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
|
||||
plane_co=(0.0, y1, 0.0), plane_no=(0.0, 1.0, 0.0),
|
||||
clear_outer=True) # drop y > y1 (ankle side, incl. the jagged rim)
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"strap band bisect y[{y0:.4f},{y1:.4f}]: {before} -> "
|
||||
f"{len(shell.data.vertices)} verts")
|
||||
if len(shell.data.vertices) == 0:
|
||||
raise RuntimeError("strap cut removed everything — window wrong?")
|
||||
|
||||
|
||||
def check_strap_rims(shell, lm):
|
||||
"""Verify the band's open edges sit ON the two cut planes (bisect gives
|
||||
this by construction; residue means ankle-rim leakage into the window)."""
|
||||
y0 = (lm.sides[+1]["y0"] + lm.sides[-1]["y0"]) / 2.0
|
||||
y1 = (lm.sides[+1]["y1"] + lm.sides[-1]["y1"]) / 2.0
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
off = [i for i in boundary
|
||||
if min(abs(bm.verts[i].co.y - y0), abs(bm.verts[i].co.y - y1)) > 1e-4]
|
||||
bm.free()
|
||||
log(f"strap rims: {len(boundary)} boundary verts, {len(off)} off-plane "
|
||||
f"(expected 0)")
|
||||
if off:
|
||||
log("WARNING: off-plane rim verts — ankle-rim residue inside the "
|
||||
"band window; lower STRAP_Y1_FRAC")
|
||||
|
||||
|
||||
def _convex_hull_2d(points):
|
||||
"""Andrew's monotone chain; returns CCW hull points (numpy (H,2))."""
|
||||
pts = np.unique(np.round(points, 6), axis=0)
|
||||
order = np.lexsort((pts[:, 1], pts[:, 0]))
|
||||
pts = pts[order]
|
||||
if len(pts) < 3:
|
||||
raise RuntimeError("degenerate footprint for convex hull")
|
||||
|
||||
def cross(o, a, b):
|
||||
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
||||
|
||||
lower = []
|
||||
for p in pts:
|
||||
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
|
||||
lower.pop()
|
||||
lower.append(p)
|
||||
upper = []
|
||||
for p in pts[::-1]:
|
||||
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
|
||||
upper.pop()
|
||||
upper.append(p)
|
||||
return np.array(lower[:-1] + upper[:-1], dtype=np.float64)
|
||||
|
||||
|
||||
def build_soles(shell, skin_pos, lm, margin, embed, drop):
|
||||
"""Append one sole prism per foot; returns (sole_start_index, sole_info).
|
||||
|
||||
Footprint = expanded convex hull of that foot's FULL skin xy (pre-cut
|
||||
snapshot); prism spans z in [skin_min - drop, skin_min + embed].
|
||||
|
||||
FLEX CREASE: each prism is bisected (no material removed) by a Y plane at
|
||||
that side's ball-joint line. Without it the long heel->toe top face
|
||||
interpolates skinning linearly across the whole span while the foot
|
||||
creases sharply at the ball during Sprint/Crouch toe-off — the slab sags
|
||||
below the bend crest and toe skin dips through (QA evidence, wave-2
|
||||
slides). The crease ring picks up ball-blended weights from the
|
||||
nearest-vertex transfer, so the slab hinges where the foot hinges.
|
||||
"""
|
||||
me = shell.data
|
||||
sole_start = len(me.vertices)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
|
||||
info = {}
|
||||
for sign in (+1, -1):
|
||||
if sign > 0:
|
||||
side_pos = skin_pos[skin_pos[:, 0] >= 0.0]
|
||||
else:
|
||||
side_pos = skin_pos[skin_pos[:, 0] < 0.0]
|
||||
if len(side_pos) == 0:
|
||||
raise RuntimeError("no skin verts on one side for sole build")
|
||||
hull = _convex_hull_2d(side_pos[:, :2])
|
||||
centroid = hull.mean(axis=0)
|
||||
d = hull - centroid
|
||||
n = d / np.linalg.norm(d, axis=1, keepdims=True)
|
||||
hull = hull + n * margin
|
||||
z_min = float(side_pos[:, 2].min())
|
||||
z_bot, z_top = z_min - drop, z_min + embed
|
||||
info[sign] = {"z_bot": z_bot, "z_top": z_top}
|
||||
|
||||
bot = [bm.verts.new((p[0], p[1], z_bot)) for p in hull]
|
||||
top = [bm.verts.new((p[0], p[1], z_top)) for p in hull]
|
||||
new_faces = []
|
||||
h = len(hull)
|
||||
for i in range(h):
|
||||
j = (i + 1) % h
|
||||
new_faces.append(bm.faces.new((bot[i], bot[j], top[j], top[i])))
|
||||
new_faces.append(bm.faces.new(top))
|
||||
new_faces.append(bm.faces.new(tuple(reversed(bot))))
|
||||
bmesh.ops.recalc_face_normals(bm, faces=new_faces)
|
||||
|
||||
# Flex crease at the ball line (cut only, keep both sides).
|
||||
crease_verts = set()
|
||||
for f in new_faces:
|
||||
crease_verts.update(f.verts)
|
||||
crease_edges = {e for v in crease_verts for e in v.link_edges}
|
||||
res = bmesh.ops.bisect_plane(
|
||||
bm,
|
||||
geom=list(crease_verts) + list(crease_edges) + new_faces,
|
||||
plane_co=(0.0, lm.sides[sign]["ball_y"], 0.0),
|
||||
plane_no=(0.0, 1.0, 0.0),
|
||||
clear_inner=False, clear_outer=False)
|
||||
cut = sum(1 for g in res["geom_cut"] if isinstance(g, bmesh.types.BMVert))
|
||||
log(f"sole {'L' if sign > 0 else 'R'}: hull {h} pts, "
|
||||
f"z[{z_bot:.4f},{z_top:.4f}], ball crease "
|
||||
f"y={lm.sides[sign]['ball_y']:.4f} ({cut} crease verts)")
|
||||
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"soles appended: verts {sole_start} -> {len(me.vertices)}")
|
||||
return sole_start, info
|
||||
|
||||
|
||||
def transfer_sole_weights(shell, skin_pos, skin_weights, sole_start):
|
||||
"""Nearest-vertex weight transfer (same-side skin snapshot) for sole verts."""
|
||||
me = shell.data
|
||||
left = skin_pos[:, 0] >= 0.0
|
||||
idx_by_side = {+1: np.where(left)[0], -1: np.where(~left)[0]}
|
||||
transferred = 0
|
||||
for vi in range(sole_start, len(me.vertices)):
|
||||
co = me.vertices[vi].co
|
||||
side = +1 if co.x >= 0.0 else -1
|
||||
cand = idx_by_side[side]
|
||||
d2 = ((skin_pos[cand] - np.array([co.x, co.y, co.z])) ** 2).sum(axis=1)
|
||||
src = int(cand[int(np.argmin(d2))])
|
||||
for gi, w in skin_weights[src]:
|
||||
shell.vertex_groups[gi].add([vi], w, 'REPLACE')
|
||||
transferred += 1
|
||||
log(f"sole weights transferred: {transferred} verts (nearest skin vert)")
|
||||
|
||||
|
||||
def clamp_strap_under_sole(shell, sole_start, sole_info):
|
||||
"""Clamp strap verts that dipped below the sole interior back inside it."""
|
||||
me = shell.data
|
||||
n = 0
|
||||
for vi in range(sole_start):
|
||||
v = me.vertices[vi]
|
||||
side = +1 if v.co.x >= 0.0 else -1
|
||||
floor_z = sole_info[side]["z_bot"] + CLAMP_INSET_M
|
||||
if v.co.z < floor_z:
|
||||
v.co.z = floor_z
|
||||
n += 1
|
||||
me.update()
|
||||
if n:
|
||||
log(f"clamped {n} strap verts above the sole bottom (hidden in slab)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Deterministic region UVs (no bpy.ops dependency)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _dominant_axis(normal):
|
||||
a = (abs(normal.x), abs(normal.y), abs(normal.z))
|
||||
return a.index(max(a))
|
||||
|
||||
|
||||
def author_region_uvs(shell, sole_start):
|
||||
"""Re-UV the garment: strap faces -> left half, sole faces -> right half,
|
||||
each split into three stacked tiles by dominant normal axis (planar
|
||||
projection). Faces within one (region, axis) tile may overlap — harmless,
|
||||
they share one flat colour — but strap/sole texels never mix."""
|
||||
me = shell.data
|
||||
while len(me.uv_layers) > 1:
|
||||
me.uv_layers.remove(me.uv_layers[-1])
|
||||
if len(me.uv_layers) == 0:
|
||||
me.uv_layers.new(name="UVMap")
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.normal_update()
|
||||
uvl = bm.loops.layers.uv[0]
|
||||
|
||||
# region 0 = strap (u 0.02..0.48), region 1 = sole (u 0.52..0.98)
|
||||
u_ranges = {0: (0.02, 0.48), 1: (0.52, 0.98)}
|
||||
proj = {0: (1, 2), 1: (0, 2), 2: (0, 1)} # axis -> (coord_a, coord_b)
|
||||
|
||||
buckets = {}
|
||||
for face in bm.faces:
|
||||
region = 1 if all(v.index >= sole_start for v in face.verts) else 0
|
||||
axis = _dominant_axis(face.normal)
|
||||
buckets.setdefault((region, axis), []).append(face)
|
||||
|
||||
for (region, axis), faces in buckets.items():
|
||||
ca, cb = proj[axis]
|
||||
pts = []
|
||||
for f in faces:
|
||||
for lo in f.loops:
|
||||
pts.append((lo.vert.co[ca], lo.vert.co[cb]))
|
||||
pts = np.array(pts)
|
||||
lo_a, hi_a = float(pts[:, 0].min()), float(pts[:, 0].max())
|
||||
lo_b, hi_b = float(pts[:, 1].min()), float(pts[:, 1].max())
|
||||
da = max(hi_a - lo_a, 1e-6)
|
||||
db = max(hi_b - lo_b, 1e-6)
|
||||
u0, u1 = u_ranges[region]
|
||||
v0 = 0.02 + axis * (1.0 / 3.0)
|
||||
v1 = v0 + (1.0 / 3.0) - 0.04
|
||||
for f in faces:
|
||||
for lo in f.loops:
|
||||
a = (lo.vert.co[ca] - lo_a) / da
|
||||
b = (lo.vert.co[cb] - lo_b) / db
|
||||
lo[uvl].uv = (u0 + a * (u1 - u0), v0 + b * (v1 - v0))
|
||||
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"region UVs authored: {len(buckets)} (region,axis) tiles")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Paint albedo + mask (per-face flat colours, shared rasterization)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _raster_tri_multi(bufs_colors, a, b, c, W, H):
|
||||
"""Barycentric fill of one UV triangle into several (buf, color) pairs."""
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
px = xs + 0.5
|
||||
py = ys + 0.5
|
||||
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
|
||||
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
for buf, color in bufs_colors:
|
||||
region = buf[miny:maxy + 1, minx:maxx + 1]
|
||||
region[inside] = np.array(color, dtype=buf.dtype)
|
||||
|
||||
|
||||
def _dilate_painted(alb, mask, flag, iters):
|
||||
"""Grow painted texels into the background so bilinear/mip bleed at tile
|
||||
edges picks up real region colours, not the background fill."""
|
||||
H, W = flag.shape
|
||||
for _ in range(iters):
|
||||
grew = np.zeros_like(flag)
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
src = np.zeros_like(flag)
|
||||
sy0, sy1 = max(dy, 0), H + min(dy, 0)
|
||||
ty0, ty1 = max(-dy, 0), H + min(-dy, 0)
|
||||
sx0, sx1 = max(dx, 0), W + min(dx, 0)
|
||||
tx0, tx1 = max(-dx, 0), W + min(-dx, 0)
|
||||
src[ty0:ty1, tx0:tx1] = flag[sy0:sy1, sx0:sx1]
|
||||
fill = (~flag) & (~grew) & src
|
||||
if not fill.any():
|
||||
continue
|
||||
alb_src = np.zeros_like(alb)
|
||||
alb_src[ty0:ty1, tx0:tx1] = alb[sy0:sy1, sx0:sx1]
|
||||
mask_src = np.zeros_like(mask)
|
||||
mask_src[ty0:ty1, tx0:tx1] = mask[sy0:sy1, sx0:sx1]
|
||||
alb[fill] = alb_src[fill]
|
||||
mask[fill] = mask_src[fill]
|
||||
grew |= fill
|
||||
flag |= grew
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, sole_start, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once: sole -> R + sole tone, strap -> G +
|
||||
strap tone. One classification drives both outputs (denim practice)."""
|
||||
W = H = TEX_SIZE
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = STRAP_RGB[c]
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = strap green (bleed-safe default)
|
||||
flag_buf = np.zeros((H, W), dtype=bool)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
uvl = bm.loops.layers.uv[0]
|
||||
|
||||
counts = {"sole": 0, "strap": 0}
|
||||
for face in bm.faces:
|
||||
is_sole = all(v.index >= sole_start for v in face.verts)
|
||||
counts["sole" if is_sole else "strap"] += 1
|
||||
alb_rgb = SOLE_RGB if is_sole else STRAP_RGB
|
||||
mask_rgba = (1.0, 0.0, 0.0, 0.0) if is_sole else (0.0, 1.0, 0.0, 0.0)
|
||||
alb_rgba = (alb_rgb[0], alb_rgb[1], alb_rgb[2], 1.0)
|
||||
uvs = [lo[uvl].uv.copy() for lo in face.loops]
|
||||
flag_view = flag_buf[:, :, None] # view — writes reach flag_buf
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_multi(
|
||||
[(alb_buf, alb_rgba), (mask_buf, mask_rgba),
|
||||
(flag_view, True)],
|
||||
uvs[0], uvs[i], uvs[i + 1], W, H)
|
||||
bm.free()
|
||||
total = max(sum(counts.values()), 1)
|
||||
log("region faces: " + " ".join(
|
||||
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
|
||||
|
||||
_dilate_painted(alb_buf, mask_buf, flag_buf, DILATE_PX)
|
||||
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise = ((rng.random((H, W, 1), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
alb_buf[:, :, :3] = np.clip(alb_buf[:, :, :3] + noise, 0.0, 1.0)
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"slides_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"slides_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
# The glTF exporter names the embedded image after the file basename, and
|
||||
# the Godot import EXTRACTS it as <glb>_<imagename>.png. Point the image at
|
||||
# the shared base_albedo.png so the extraction lands exactly on the sidecar
|
||||
# saved above (<body>_base_albedo.png, same pixels — hoodie/tshirt
|
||||
# convention), instead of a doubled <body>_<body>_base_albedo.png whose
|
||||
# deletion would break the imported scene. The shared file is re-pointed
|
||||
# to the reference body's paint at the end of the run (main()).
|
||||
albedo_img.filepath_raw = os.path.join(os.path.dirname(albedo_path),
|
||||
"base_albedo.png")
|
||||
albedo_img.save()
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_slides(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell) # merge UV-seam/segment duplicate verts
|
||||
|
||||
lm = FootLandmarks(armature)
|
||||
skin_pos, skin_weights = snapshot_skin(shell)
|
||||
|
||||
strap_cut(shell, lm)
|
||||
check_strap_rims(shell, lm)
|
||||
base.offset_outward(shell, offset)
|
||||
base.solidify(shell, STRAP_THICKNESS_M)
|
||||
|
||||
sole_start, sole_info = build_soles(
|
||||
shell, skin_pos, lm,
|
||||
SOLE_MARGIN_M * lm.scale, SOLE_EMBED_M * lm.scale,
|
||||
SOLE_DROP_M * lm.scale)
|
||||
transfer_sole_weights(shell, skin_pos, skin_weights, sole_start)
|
||||
clamp_strap_under_sole(shell, sole_start, sole_info)
|
||||
|
||||
author_region_uvs(shell, sole_start)
|
||||
denim.author_parked_uv2(shell) # slides are not logo-capable
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, sole_start, albedo_path,
|
||||
mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global STRAP_Y0_FRAC, STRAP_Y1_FRAC, STRAP_RGB, SOLE_RGB
|
||||
global SOLE_MARGIN_M, SOLE_EMBED_M, SOLE_DROP_M, NOISE_SEED
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--strap-y0 F] [--strap-y1 F] [--sole-margin M] "
|
||||
"[--sole-embed M] [--sole-drop M] [--strap-rgb r,g,b] "
|
||||
"[--sole-rgb r,g,b] [--seed N]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
|
||||
def _f(flag, default):
|
||||
return float(argv[argv.index(flag) + 1]) if flag in argv else default
|
||||
|
||||
def _rgb(flag, default):
|
||||
if flag not in argv:
|
||||
return default
|
||||
return tuple(float(v) for v in argv[argv.index(flag) + 1].split(","))
|
||||
|
||||
offset = _f("--offset", STRAP_OFFSET_M)
|
||||
STRAP_Y0_FRAC = _f("--strap-y0", STRAP_Y0_FRAC)
|
||||
STRAP_Y1_FRAC = _f("--strap-y1", STRAP_Y1_FRAC)
|
||||
SOLE_MARGIN_M = _f("--sole-margin", SOLE_MARGIN_M)
|
||||
SOLE_EMBED_M = _f("--sole-embed", SOLE_EMBED_M)
|
||||
SOLE_DROP_M = _f("--sole-drop", SOLE_DROP_M)
|
||||
NOISE_SEED = int(_f("--seed", NOISE_SEED))
|
||||
STRAP_RGB = _rgb("--strap-rgb", STRAP_RGB)
|
||||
SOLE_RGB = _rgb("--sole-rgb", SOLE_RGB)
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"slides per-body mode: {len(bodies)} bodies, strap offset "
|
||||
f"{offset * 1000:.0f} mm, band frac [{STRAP_Y0_FRAC},{STRAP_Y1_FRAC}]")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_slides(body_dir, out_dir, body, offset)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,765 @@
|
||||
"""
|
||||
blender_author_sneakers.py (T-1089 wave 2, sneakers_modern + trainer family)
|
||||
|
||||
Authors low-top TRAINERS as per-body offset shells over BOTH feet (seg_foot_l +
|
||||
seg_foot_r joined into ONE garment — the peasant_shoes both-feet convention),
|
||||
reusing blender_author_offset_shell.py as a library (scene build, join, offset,
|
||||
solidify, GLB export) and blender_author_denim_pants.py's boundary weld.
|
||||
|
||||
What footwear needs beyond the bottoms family, as reusable parameters:
|
||||
|
||||
* boundary WELD first (denim practice) — the raw foot segment is NOT one
|
||||
surface: the sole is a separate coincident-vert patch and the ankle cut
|
||||
leaves floating shards (probe: average_m = 4 islands, muscular_m/child = 8).
|
||||
remove_doubles at 0.5 mm fuses everything into one watertight-except-ankle
|
||||
shell per foot; weights identical by origin, so skinning is unaffected.
|
||||
* COLLAR cut + rim flatten — the segment splitter's ankle boundary is jagged
|
||||
weight-threshold teeth (3.3-8.3 cm across bodies, back-biased). Verts above
|
||||
the collar plane (a fraction of the ankle-joint height) are deleted, then
|
||||
every remaining boundary vert is pulled ONTO the collar plane — a clean
|
||||
horizontal low-top opening. Re-flattened after the outward offset because
|
||||
rim-vert normals have a +z bias that would lift the rim.
|
||||
* SOLE slab (this family's new offset-shell param) — the rim-flatten practice
|
||||
applied to the ground plane, built as cut + flatten + extrude + fill: the
|
||||
shell's underside band is cut away (with the toe-knuckle lobes that survive
|
||||
smoothing), the open rim is flattened onto the cut plane and its outline
|
||||
relaxed, then extruded straight down to a plane `--sole-drop` (scaled per
|
||||
body) below the body's own foot-bottom and closed with a flat bottom — a
|
||||
clean prism slab; Solidify thickens it and a final clamp guarantees the
|
||||
outer sole is planar. (Snapping the band in place instead collapses mesh
|
||||
rows into crumpled slivers — melted-wax scallops on the probe renders.)
|
||||
* TOE-BOX SMOOTHING + ROUNDING — the body feet have INDIVIDUAL TOES; a raw
|
||||
offset shell reads as a foot-shaped slipper (verified on average_m). Heavy
|
||||
iterative vertex smoothing over the toe region (feathered, boundary rim
|
||||
pinned) melts the toe creases into one volume, a light global pass
|
||||
de-lumps the ankle anatomy, then an extra normal-along inflation feathered
|
||||
toward the toe tip restores the lost volume as a rounded sneaker toe box.
|
||||
* COLLAR FLARE — small feathered radial stand-off at the opening (the jeans
|
||||
waist-flare practice) for ankle-flex clearance under Walk/Crouch.
|
||||
* TEXEL-level feature painting (denim practice): one analytic field drives
|
||||
BOTH the albedo and the region mask, so they always agree:
|
||||
- albedo: painted lace cross-straps over a darker tongue panel, toe cap
|
||||
+ border line, foxing stripe at the sole top, heel tab, collar band,
|
||||
vamp + heel-counter panel lines (swoosh-free — no brand marks).
|
||||
Everyday default: white/grey, flat toon-friendly tones.
|
||||
- mask: sole -> R, upper -> G, laces + trim (collar band, toe cap,
|
||||
heel tab) -> B (spec: sole=R, upper=G, laces+trim=B).
|
||||
* a parked logo_uv TEXCOORD_1 layer (all UVs at (2,2)) — not logo-capable,
|
||||
but toon_garment.gdshader samples UV2 unconditionally.
|
||||
|
||||
All parameters derive PER BODY from that body's own bone landmarks (foot_l /
|
||||
ball_l) and measured mesh extents (foot length, half-width, ground plane),
|
||||
scaled by foot length against the hand-calibrated average_m reference — the
|
||||
same proportional-ratio philosophy as base.derive_thresholds. Per-body mode
|
||||
only (offset shells author per body, Q-060). Left/right feet share body-atlas
|
||||
UV space whose islands may overlap; every painted feature is |x|-mirror
|
||||
symmetric, so overlapping texels agree by construction.
|
||||
|
||||
Usage (sneakers_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_sneakers.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/sneakers_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.009] [--sole-drop 0.015] \
|
||||
[--sole-snap-frac 0.55] [--collar-frac 0.95] [--collar-flare 0.002] \
|
||||
[--toe-round 0.004] [--toe-smooth 12] [--laces 4] [--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module + the denim module (weld utility)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(mod_name, file_name):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
mod_name, os.path.join(_HERE, file_name))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load("offset_shell_base", "blender_author_offset_shell.py")
|
||||
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
|
||||
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (toes point -Y)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (CLI-overridable ones are module globals)
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = ["seg_foot_l", "seg_foot_r"]
|
||||
|
||||
OFFSET_M = 0.009 # shoe standoff — snugger than cloth (12 mm)
|
||||
SOLE_DROP_M = 0.015 # sole slab depth below the body's own foot bottom
|
||||
# (scaled by foot length per body)
|
||||
SOLE_SNAP_FRAC = 0.55 # sole CUT height as a fraction of the sole rise
|
||||
# (ground -> foxing top): everything below is cut
|
||||
# away and rebuilt as an extruded prism slab (see
|
||||
# build_sole_slab) — kills the toe-knuckle underside
|
||||
# lobes that survive smoothing (probe, average_m)
|
||||
COLLAR_FRAC = 0.95 # collar plane as fraction of ankle-joint height
|
||||
COLLAR_FLARE_M = 0.002 # radial stand-off at the opening (ankle-flex room)
|
||||
TOE_ROUND_M = 0.004 # extra feathered inflation at the toe box
|
||||
SMOOTH_GLOBAL_ITERS = 2 # light whole-shell de-lumping passes
|
||||
SMOOTH_TOE_ITERS = 18 # heavy toe-crease melting passes (12 left visible
|
||||
# knuckle grooves on the probe renders)
|
||||
SMOOTH_FACTOR = 0.5
|
||||
N_LACES = 4 # painted cross-straps
|
||||
PLAIN = False # --plain: flat upper, no painted features
|
||||
TEX_SIZE = 1024 # painted lace/panel lines need > 512
|
||||
NOISE_SEED = 3089
|
||||
|
||||
# Vertical proportions — fractions of the collar height above the ground.
|
||||
SOLE_TOP_FRAC = 0.30 # sole sidewall (foxing) top -> R region below this
|
||||
COLLAR_BAND_FRAC = 0.15 # collar trim band height (B region)
|
||||
VAMP_LINE_FRAC = 0.42 # side panel line height between sole top and collar
|
||||
|
||||
# Foot-axis proportions — fractions of foot length / half-width.
|
||||
TOE_CAP_FRAC = 0.18 # toe cap depth from the toe tip (B region)
|
||||
HEEL_LINE_FRAC = 0.22 # heel-counter panel line from the heel tip
|
||||
LACE_T0, LACE_T1 = 0.18, 0.80 # lace panel span along the foot bone
|
||||
LACE_HALFW_FRAC = 0.40 # lace panel half-width, of foot half-width
|
||||
LACE_STRIPE_DUTY = 0.44 # stripe thickness as a fraction of stripe spacing
|
||||
LINE_W_M = 0.0035 # painted panel/border line half-width (scaled)
|
||||
HEEL_TAB_HALFW_M = 0.012 # heel tab half-width (scaled)
|
||||
|
||||
# Everyday default: white/grey, flat toon-friendly tones (sRGB floats).
|
||||
UPPER_RGB = (0.880, 0.880, 0.890)
|
||||
SOLE_RGB = (0.780, 0.790, 0.800)
|
||||
TREAD_RGB = (0.450, 0.460, 0.480) # below-ground outsole
|
||||
TOE_RGB = (0.920, 0.920, 0.930) # toe bumper
|
||||
LACE_RGB = (0.960, 0.960, 0.965)
|
||||
TONGUE_SHADE = 0.90 # lace-zone panel behind the straps
|
||||
COLLAR_SHADE = 0.88 # collar band darkening
|
||||
HEEL_TAB_SHADE = 0.72
|
||||
LINE_SHADE = 0.74 # painted panel/border lines
|
||||
ALBEDO_NOISE = 0.015
|
||||
|
||||
# Reference proportions (average_m) the fractions were calibrated against.
|
||||
_REF_FOOT_LEN = 0.2704 # heel y (0.1374) - toe tip y (-0.1330)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FootLandmarks:
|
||||
"""Cut/mask/paint parameters from one body's foot bones + measured mesh."""
|
||||
|
||||
def __init__(self, armature, shell):
|
||||
bones = armature.data.bones
|
||||
foot = bones.get("foot_l")
|
||||
ball = bones.get("ball_l")
|
||||
if foot is None or ball is None:
|
||||
raise RuntimeError("foot_l/ball_l missing — not the 65-bone rig?")
|
||||
# Bone landmarks (left foot; the right mirrors via |x|).
|
||||
self.ankle_y = foot.head_local.y
|
||||
self.ankle_z = foot.head_local.z
|
||||
self.ball_y = foot.tail_local.y
|
||||
self.ball_z = foot.tail_local.z
|
||||
self.toe_y = ball.tail_local.y
|
||||
# Measured mesh extents (left-foot verts; feet are x-mirror symmetric).
|
||||
lx = [v.co.x for v in shell.data.vertices if v.co.x > 0.0]
|
||||
ly = [v.co.y for v in shell.data.vertices if v.co.x > 0.0]
|
||||
zs = [v.co.z for v in shell.data.vertices]
|
||||
self.foot_cx = (min(lx) + max(lx)) / 2.0
|
||||
self.half_w = (max(lx) - min(lx)) / 2.0
|
||||
self.heel_y = max(ly)
|
||||
self.toe_tip_y = min(ly)
|
||||
self.ground_z = min(zs)
|
||||
self.foot_len = self.heel_y - self.toe_tip_y
|
||||
self.s = self.foot_len / _REF_FOOT_LEN
|
||||
|
||||
self.collar_z = self.ground_z + COLLAR_FRAC * (self.ankle_z - self.ground_z)
|
||||
self.sole_drop = SOLE_DROP_M * self.s
|
||||
self.sole_bottom = self.ground_z - self.sole_drop
|
||||
rise = self.collar_z - self.ground_z
|
||||
self.sole_top = self.ground_z + SOLE_TOP_FRAC * rise
|
||||
self.band_h = COLLAR_BAND_FRAC * rise
|
||||
self.vamp_z = self.sole_top + VAMP_LINE_FRAC * (self.collar_z - self.sole_top)
|
||||
self.cap_y = self.toe_tip_y + TOE_CAP_FRAC * self.foot_len
|
||||
self.heel_line_y = self.heel_y - HEEL_LINE_FRAC * self.foot_len
|
||||
self.lace_halfw = LACE_HALFW_FRAC * self.half_w
|
||||
self.line_w = LINE_W_M * self.s
|
||||
log(f"landmarks: ankle_z={self.ankle_z:.4f} collar_z={self.collar_z:.4f} "
|
||||
f"ground={self.ground_z:.4f} sole_bottom={self.sole_bottom:.4f} "
|
||||
f"sole_top={self.sole_top:.4f} foot_len={self.foot_len:.4f} "
|
||||
f"half_w={self.half_w:.4f} cx={self.foot_cx:.4f} s={self.s:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: collar cut + flatten, sole slab, toe round, collar flare
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def make_normals_consistent(shell):
|
||||
"""Outward-consistent normals BEFORE the offset — the raw foot's sole patch
|
||||
winds independently of the upper, so post-weld normals need one recalc or
|
||||
the offset would pull the sole inward."""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
shell.select_set(True)
|
||||
bpy.context.view_layer.objects.active = shell
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.normals_make_consistent(inside=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
log("recalculated outward-consistent normals")
|
||||
|
||||
|
||||
def collar_cut(shell, collar_z):
|
||||
"""Delete verts above the collar plane (bone-plane-cut practice)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts if v.co.z > collar_z]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"collar cut at z={collar_z:.4f}: removed {len(doomed)} verts")
|
||||
|
||||
|
||||
def flatten_collar_rims(shell, collar_z, label):
|
||||
"""Pull every open-boundary vert ONTO the collar plane (rim-flatten
|
||||
practice). After the weld + collar cut the only open boundary is the two
|
||||
collar rims, so a single pass flattens both feet."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
if not boundary:
|
||||
bm.free()
|
||||
raise RuntimeError("no open collar boundary found after cut")
|
||||
zs = [bm.verts[i].co.z for i in boundary]
|
||||
for i in boundary:
|
||||
bm.verts[i].co.z = collar_z
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened collar rims ({label}): {len(boundary)} verts, "
|
||||
f"z {min(zs):.4f}..{max(zs):.4f} -> {collar_z:.4f}")
|
||||
|
||||
|
||||
def smooth_shell(shell, lm):
|
||||
"""Melt the individual toes into one toe box and de-lump the ankle
|
||||
anatomy: iterative vertex smoothing, heavier toward the toe, with the open
|
||||
collar boundary pinned so the flattened rim stays put. Runs pre-offset;
|
||||
the 9 mm standoff + toe inflation restore the smoothing's volume loss."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
interior = [v for v in bm.verts if v.index not in boundary]
|
||||
toe_lim = lm.cap_y + 0.10 * lm.foot_len
|
||||
toe_verts = [v for v in interior if v.co.y < toe_lim]
|
||||
for _ in range(SMOOTH_GLOBAL_ITERS):
|
||||
bmesh.ops.smooth_vert(bm, verts=interior, factor=SMOOTH_FACTOR,
|
||||
use_axis_x=True, use_axis_y=True, use_axis_z=True)
|
||||
for _ in range(SMOOTH_TOE_ITERS):
|
||||
bmesh.ops.smooth_vert(bm, verts=toe_verts, factor=SMOOTH_FACTOR,
|
||||
use_axis_x=True, use_axis_y=True, use_axis_z=True)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"smoothed shell: {SMOOTH_GLOBAL_ITERS} global passes "
|
||||
f"({len(interior)} verts), {SMOOTH_TOE_ITERS} toe passes "
|
||||
f"({len(toe_verts)} verts, y < {toe_lim:.4f})")
|
||||
|
||||
|
||||
def toe_round(shell, lm, amount):
|
||||
"""Extra normal-along inflation feathered toward the toe tip — swallows
|
||||
the foot's toe detail into one rounded sneaker toe box."""
|
||||
if amount <= 0.0:
|
||||
return
|
||||
span = lm.cap_y - lm.toe_tip_y
|
||||
if span <= 1e-6:
|
||||
return
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
n = 0
|
||||
for v in bm.verts:
|
||||
if v.co.y < lm.cap_y:
|
||||
t = min((lm.cap_y - v.co.y) / span, 1.0)
|
||||
v.co += v.normal * (amount * t)
|
||||
n += 1
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"toe rounding: {n} verts, +{amount * 1000:.1f} mm feathered")
|
||||
|
||||
|
||||
RIM_RELAX_ITERS = 3 # along-ring XY relaxation of the cut rim outline
|
||||
|
||||
|
||||
def build_sole_slab(shell, lm):
|
||||
"""Rim-flatten practice applied to the GROUND plane, done PROPERLY as a
|
||||
cut + flatten + extrude (snapping a whole z-band onto the plane collapses
|
||||
multiple mesh rows into crumpled slivers that read as melted-wax scallops
|
||||
— probe renders v3-v5):
|
||||
|
||||
1. DELETE everything below the cut height (SOLE_SNAP_FRAC of the sole
|
||||
rise) — removes the toe-knuckle underside lobes outright.
|
||||
2. RIM-FLATTEN the resulting open bottom boundary onto the cut plane
|
||||
(exactly the denim ankle practice), then relax the ring outline in
|
||||
XY along the ring only — a smooth footprint curve, rounded toe.
|
||||
3. EXTRUDE the ring straight down to the sole plane — a clean vertical
|
||||
prism wall (extruded verts inherit the rim verts' deform weights,
|
||||
so the sole still flexes at the ball joint).
|
||||
4. FILL the bottom ring with faces — a closed flat underside.
|
||||
|
||||
Solidify then grows the outer surface CLOTH_THICKNESS_M further down,
|
||||
landing the visible outsole on lm.sole_bottom (guaranteed by
|
||||
clamp_residue after solidify)."""
|
||||
plane = lm.sole_bottom + base.CLOTH_THICKNESS_M
|
||||
hi = lm.ground_z + SOLE_SNAP_FRAC * (lm.sole_top - lm.ground_z)
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
|
||||
# 1. cut
|
||||
doomed = [v for v in bm.verts if v.co.z < hi]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
|
||||
# 2. rim-flatten (the collar rim is also open — split boundaries by z)
|
||||
boundary = {v for e in bm.edges if len(e.link_faces) == 1
|
||||
for v in e.verts}
|
||||
z_mid = (lm.collar_z + hi) / 2.0
|
||||
sole_rim = {v for v in boundary if v.co.z < z_mid}
|
||||
if not sole_rim:
|
||||
bm.free()
|
||||
raise RuntimeError("no sole rim found after cut — check cut height")
|
||||
for v in sole_rim:
|
||||
v.co.z = hi
|
||||
# along-ring XY relax: average each rim vert with its ring neighbours
|
||||
# only (bmesh smooth_vert would pull toward the upper rows and shrink)
|
||||
for _ in range(RIM_RELAX_ITERS):
|
||||
new_pos = {}
|
||||
for v in sole_rim:
|
||||
ring_nbrs = [e.other_vert(v) for e in v.link_edges
|
||||
if len(e.link_faces) == 1
|
||||
and e.other_vert(v) in sole_rim]
|
||||
if len(ring_nbrs) >= 2:
|
||||
ax = sum(n.co.x for n in ring_nbrs) / len(ring_nbrs)
|
||||
ay = sum(n.co.y for n in ring_nbrs) / len(ring_nbrs)
|
||||
new_pos[v] = (v.co.x + 0.5 * (ax - v.co.x),
|
||||
v.co.y + 0.5 * (ay - v.co.y))
|
||||
for v, (x, y) in new_pos.items():
|
||||
v.co.x = x
|
||||
v.co.y = y
|
||||
|
||||
# 3. extrude the rim edges straight down to the sole plane
|
||||
rim_edges = [e for e in bm.edges if len(e.link_faces) == 1
|
||||
and e.verts[0] in sole_rim and e.verts[1] in sole_rim]
|
||||
ret = bmesh.ops.extrude_edge_only(bm, edges=rim_edges)
|
||||
new_verts = [g for g in ret["geom"]
|
||||
if isinstance(g, bmesh.types.BMVert)]
|
||||
for v in new_verts:
|
||||
v.co.z = plane
|
||||
|
||||
# 4. close the bottom
|
||||
bottom_edges = [e for e in bm.edges if len(e.link_faces) == 1
|
||||
and all(abs(v.co.z - plane) < 1e-6 for v in e.verts)]
|
||||
filled = bmesh.ops.holes_fill(bm, edges=bottom_edges, sides=0)
|
||||
|
||||
# 5. UV-park the new faces. The fill n-gon's default loop UVs span the
|
||||
# whole enclosed UV region — its texel bake overwrites painted islands
|
||||
# (probe v6: mottled patches, features erased); wall quads' copied UVs
|
||||
# sit ON the island boundary where bilinear sampling picks up
|
||||
# background. ONE park point per foot (per-face points sample texels of
|
||||
# varying paint state and stripe the wall — probe v7): all new faces on
|
||||
# a side park on the UV centre of that side's LARGEST-UV-area
|
||||
# rim-adjacent face — big enough that the bake reliably rasterizes its
|
||||
# interior, and its centre lies in the sole band (z <= sole_top), so
|
||||
# the sampled texel is the flat sole tone with an R-region mask.
|
||||
uv_layer = bm.loops.layers.uv[0] if len(bm.loops.layers.uv) else None
|
||||
if uv_layer is not None:
|
||||
new_faces = [g for g in ret["geom"]
|
||||
if isinstance(g, bmesh.types.BMFace)]
|
||||
new_faces += list(filled["faces"])
|
||||
new_face_set = set(new_faces)
|
||||
best = {} # x-sign side -> (uv_area, centre uv); feet never cross x=0
|
||||
for v in sole_rim:
|
||||
side = 1 if v.co.x >= 0.0 else -1
|
||||
for loop in v.link_loops:
|
||||
f = loop.face
|
||||
if f in new_face_set:
|
||||
continue
|
||||
us = [lp[uv_layer].uv for lp in f.loops]
|
||||
area = 0.0
|
||||
for i in range(len(us)):
|
||||
j = (i + 1) % len(us)
|
||||
area += us[i].x * us[j].y - us[j].x * us[i].y
|
||||
area = abs(area) * 0.5
|
||||
if side not in best or area > best[side][0]:
|
||||
best[side] = (area,
|
||||
(sum(u.x for u in us) / len(us),
|
||||
sum(u.y for u in us) / len(us)))
|
||||
for f in new_faces:
|
||||
side = 1 if sum(v.co.x for v in f.verts) >= 0.0 else -1
|
||||
if side not in best:
|
||||
side = -side
|
||||
uv = best[side][1]
|
||||
for loop in f.loops:
|
||||
loop[uv_layer].uv = uv
|
||||
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"sole slab: cut {len(doomed)} verts (z < {hi:.4f}), rim "
|
||||
f"{len(sole_rim)} verts -> z={hi:.4f}, extruded {len(new_verts)} "
|
||||
f"verts -> z={plane:.4f}, filled {len(filled['faces'])} bottom faces")
|
||||
|
||||
|
||||
def collar_flare(shell, lm, flare):
|
||||
"""Feathered radial stand-off at the opening (waist-flare practice) —
|
||||
ankle-flex clearance under Walk/Crouch."""
|
||||
if flare <= 0.0:
|
||||
return
|
||||
z0 = lm.collar_z - 2.0 * lm.band_h
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
n = 0
|
||||
for v in bm.verts:
|
||||
if v.co.z > z0:
|
||||
t = min((v.co.z - z0) / (2.0 * lm.band_h), 1.0)
|
||||
nx, ny = v.normal.x, v.normal.y
|
||||
mag = (nx * nx + ny * ny) ** 0.5
|
||||
if mag > 1e-6:
|
||||
v.co.x += flare * t * nx / mag
|
||||
v.co.y += flare * t * ny / mag
|
||||
n += 1
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"collar flare: {n} verts, +{flare * 1000:.1f} mm radial at rim")
|
||||
|
||||
|
||||
def clamp_residue(shell, lm):
|
||||
"""Post-solidify safety clamps: the rim cap can push verts above the
|
||||
collar plane, and blended edge normals leave the outsole slightly uneven —
|
||||
squash both back onto their planes."""
|
||||
me = shell.data
|
||||
n_top = n_bot = 0
|
||||
for v in me.vertices:
|
||||
if v.co.z > lm.collar_z:
|
||||
v.co.z = lm.collar_z
|
||||
n_top += 1
|
||||
elif v.co.z < lm.sole_bottom:
|
||||
v.co.z = lm.sole_bottom
|
||||
n_bot += 1
|
||||
me.update()
|
||||
log(f"clamped residue: {n_top} collar verts -> {lm.collar_z:.4f}, "
|
||||
f"{n_bot} sole verts -> {lm.sole_bottom:.4f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sneaker feature field (texel-level; drives albedo AND mask together).
|
||||
# Every feature is |x|-mirror symmetric so the (possibly overlapping)
|
||||
# left/right UV islands paint identical values.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
dx = px - side * lm.foot_cx # signed offset from the foot centre
|
||||
adx = np.abs(dx)
|
||||
|
||||
in_sole = pz <= lm.sole_top
|
||||
# Tread = the flat underside plane only (never visible from the side).
|
||||
# The sole SIDE WALL is snapped geometry whose UV triangles are stretched
|
||||
# slivers — any tonal variation there (noise, a dark tread band) smears
|
||||
# into vertical streaks under bilinear magnification, so the whole band
|
||||
# above the underside is painted ONE flat tone.
|
||||
in_tread = pz <= lm.sole_bottom + 0.0015
|
||||
in_band = (pz >= lm.collar_z - lm.band_h) & ~in_sole
|
||||
|
||||
# Lace panel: param t along the foot bone (ankle head -> ball tail) in
|
||||
# the (y,z) plane; texels above the bone line, near the centreline.
|
||||
dy_ax = lm.ball_y - lm.ankle_y
|
||||
dz_ax = lm.ball_z - lm.ankle_z
|
||||
l2 = dy_ax * dy_ax + dz_ax * dz_ax
|
||||
t = ((py - lm.ankle_y) * dy_ax + (pz - lm.ankle_z) * dz_ax) / l2
|
||||
above_bone = pz > (lm.ankle_z + t * dz_ax + 0.002 * lm.s)
|
||||
in_tongue = (~in_sole & above_bone & (adx < lm.lace_halfw)
|
||||
& (t >= LACE_T0) & (t <= LACE_T1))
|
||||
frac = (t - LACE_T0) / (LACE_T1 - LACE_T0)
|
||||
stripe_pos = frac * N_LACES
|
||||
stripe_d = np.abs(stripe_pos - (np.floor(stripe_pos) + 0.5))
|
||||
laces = in_tongue & (stripe_d < 0.5 * LACE_STRIPE_DUTY)
|
||||
|
||||
toe_cap = (py < lm.cap_y) & ~in_sole
|
||||
heel_tab = ((adx < HEEL_TAB_HALFW_M * lm.s) & ~in_sole
|
||||
& (py > lm.ankle_y + 0.55 * (lm.heel_y - lm.ankle_y)))
|
||||
|
||||
# --- albedo -------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = UPPER_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
for c in range(3):
|
||||
alb[in_sole, c] = SOLE_RGB[c] # flat, noise-free (sliver UVs)
|
||||
alb[in_tread, c] = TREAD_RGB[c] # underside plane only
|
||||
|
||||
if not PLAIN:
|
||||
for c in range(3):
|
||||
alb[toe_cap & ~in_sole, c] = TOE_RGB[c] + noise[toe_cap & ~in_sole]
|
||||
alb[in_tongue, :3] *= TONGUE_SHADE
|
||||
for c in range(3):
|
||||
alb[laces, c] = LACE_RGB[c] + noise[laces]
|
||||
alb[in_band & ~laces, :3] *= COLLAR_SHADE
|
||||
alb[heel_tab & ~in_band, :3] *= HEEL_TAB_SHADE
|
||||
|
||||
# Painted panel lines (albedo only — swoosh-free).
|
||||
foxing = np.abs(pz - lm.sole_top) < lm.line_w
|
||||
cap_border = (np.abs(py - lm.cap_y) < lm.line_w) & ~in_sole
|
||||
vamp = ((np.abs(pz - lm.vamp_z) < lm.line_w) & ~in_sole
|
||||
& (adx > lm.lace_halfw * 0.8)
|
||||
& (py > lm.cap_y) & (py < lm.heel_line_y))
|
||||
heel_ctr = ((np.abs(py - lm.heel_line_y) < lm.line_w) & ~in_sole
|
||||
& (pz < lm.collar_z - lm.band_h))
|
||||
lines = (foxing | cap_border | vamp | heel_ctr) & ~laces
|
||||
alb[lines, :3] *= LINE_SHADE
|
||||
|
||||
# --- region mask: sole R / upper G / laces+trim B -------------------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_b = (laces | toe_cap | heel_tab | in_band) & ~in_sole
|
||||
mask[in_sole, 0] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[~(in_sole | is_b), 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the sneaker field, write albedo + mask together."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = UPPER_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = upper green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
counts = [float(mask_buf[:, :, c].sum()) for c in range(3)]
|
||||
total = max(sum(counts), 1.0)
|
||||
log(f"painted {tri_count} UV triangles ({W}x{H}); mask texels "
|
||||
f"R={100 * counts[0] / total:.1f}% G={100 * counts[1] / total:.1f}% "
|
||||
f"B={100 * counts[2] / total:.1f}%")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"sneaker_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"sneaker_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_sneaker_shell(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell) # fuse sole patch + ankle shards
|
||||
make_normals_consistent(shell)
|
||||
|
||||
lm = FootLandmarks(armature, shell)
|
||||
|
||||
collar_cut(shell, lm.collar_z)
|
||||
smooth_shell(shell, lm)
|
||||
flatten_collar_rims(shell, lm.collar_z, "pre-offset")
|
||||
base.offset_outward(shell, offset)
|
||||
flatten_collar_rims(shell, lm.collar_z, "post-offset") # rim normals lift it
|
||||
toe_round(shell, lm, TOE_ROUND_M * lm.s)
|
||||
build_sole_slab(shell, lm)
|
||||
collar_flare(shell, lm, COLLAR_FLARE_M * lm.s)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
clamp_residue(shell, lm)
|
||||
|
||||
denim.author_parked_uv2(shell) # not logo-capable; shader needs UV2
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global OFFSET_M, SOLE_DROP_M, COLLAR_FRAC, COLLAR_FLARE_M
|
||||
global TOE_ROUND_M, N_LACES, PLAIN, SOLE_SNAP_FRAC, SMOOTH_TOE_ITERS
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--sole-drop M] [--sole-snap-frac F] "
|
||||
"[--collar-frac F] [--collar-flare M] [--toe-round M] "
|
||||
"[--toe-smooth N] [--laces N] [--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
if "--offset" in argv:
|
||||
OFFSET_M = float(argv[argv.index("--offset") + 1])
|
||||
if "--sole-drop" in argv:
|
||||
SOLE_DROP_M = float(argv[argv.index("--sole-drop") + 1])
|
||||
if "--sole-snap-frac" in argv:
|
||||
SOLE_SNAP_FRAC = float(argv[argv.index("--sole-snap-frac") + 1])
|
||||
if "--toe-smooth" in argv:
|
||||
SMOOTH_TOE_ITERS = int(argv[argv.index("--toe-smooth") + 1])
|
||||
if "--collar-frac" in argv:
|
||||
COLLAR_FRAC = float(argv[argv.index("--collar-frac") + 1])
|
||||
if "--collar-flare" in argv:
|
||||
COLLAR_FLARE_M = float(argv[argv.index("--collar-flare") + 1])
|
||||
if "--toe-round" in argv:
|
||||
TOE_ROUND_M = float(argv[argv.index("--toe-round") + 1])
|
||||
if "--laces" in argv:
|
||||
N_LACES = int(argv[argv.index("--laces") + 1])
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"sneaker per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{OFFSET_M * 1000:.0f} mm, sole-drop {SOLE_DROP_M * 1000:.0f} mm, "
|
||||
f"collar-frac {COLLAR_FRAC}, laces {N_LACES}, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_sneaker_shell(body_dir, out_dir, body, OFFSET_M)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,597 @@
|
||||
"""
|
||||
blender_author_sweater.py (T-1089 wave 2 — sweater_modern, per-body offset shell)
|
||||
|
||||
Crew-neck pullover authored per body via the offset-shell route. Imports
|
||||
blender_author_offset_shell.py as the shared library (segment join, bone-ratio
|
||||
thresholds, offset, solidify, logo UV2, GLB export) and
|
||||
blender_author_denim_pants.py for the wave-1-proven boundary practices
|
||||
(coincident segment-seam WELD, seg_hips crotch probe). What the sweater adds,
|
||||
as reusable parameters:
|
||||
|
||||
* coverage: torso + torso_upper + FULL arms (cuffs at the wrist) + the UPPER
|
||||
seg_hips band. Including seg_hips is the hem answer for tops: the natural
|
||||
seg_torso bottom boundary is a 9-14 cm jagged tooth ring (probe evidence,
|
||||
all bodies) and flattening it up/down either opens skin holes (hips skin
|
||||
fails to reach the teeth tops in 2/24 azimuth bins) or invents cloth off
|
||||
the body surface. Instead the shell continues into real hip geometry and
|
||||
is hem-CUT at a clean plane derived from bone landmarks (spine_01 ->
|
||||
crotch fraction), then the cut ring is flattened onto that plane
|
||||
(denim-pants ankle practice; pull distance ~ one face, not full teeth).
|
||||
Hips stay UNHIDDEN in coverage.json — skin continues under the hem, so no
|
||||
hole is geometrically possible (uniform_utility waist-join precedent).
|
||||
* crew-neck CUT + rim CIRCULARIZATION — the seg_torso_upper neckline is a
|
||||
jagged tooth ring whose VALLEY sits at neck_z + ~0.23*neck_len on every
|
||||
body (probe evidence, 6 bodies) — exactly crew height — while its teeth
|
||||
reach ~72% up the neck. Raising the rim to the teeth max (the first
|
||||
draft) reads as a chin-high mock-neck, not the crew the spec asks. So:
|
||||
delete the neck-tube verts ABOVE the valley within the ring's own
|
||||
measured hd_max * CUT_R_MUL (per-body self-calibrating radius — the
|
||||
neck-trap flare shell probes strictly outside it), then flatten the new
|
||||
boundary onto the valley plane: verts within r_med * CREW_RIM_R_MUL of
|
||||
the neck axis are circularized to the ring median radius (clean crew
|
||||
rim); flare verts beyond only drop to the plane (no radial pinch, which
|
||||
would fold cloth on wide-trap bodies). Skin-hole-safe: seg_neck skin
|
||||
reaches down exactly to the valley (probe), and runtime never hides
|
||||
segments anyway.
|
||||
* wrist rim flattening onto the cut plane (denim ankle practice on the arm
|
||||
axis) — the vert-threshold cut leaves 1-3 cm teeth; the painted ribbed
|
||||
cuff needs a clean edge.
|
||||
* cuff PINCH — per-vert offset scale tapering to CUFF_PINCH at the wrist so
|
||||
the ribbed cuffs read cinched (knit hugs the wrist).
|
||||
* painted knit identity, driven per texel from final 3D positions
|
||||
(buttondown per-pixel bake machinery): ribbed collar / cuffs / hem —
|
||||
azimuthal rib stripes around the neck axis, arm axis, and torso axis —
|
||||
plus border stitch lines. All LUMINANCE detail, so the luma-preserving
|
||||
toon_garment recolor keeps it under any tint. Warm muted default tints
|
||||
live in the manifest entry, not the albedo.
|
||||
* region mask (spec): collar=R (tint_0), body=G (tint_1), cuffs+hem=B
|
||||
(tint_2). A unused. Logo-capable OFF per spec, but the UV2 chest channel
|
||||
is still authored (costs nothing; buttondown precedent).
|
||||
|
||||
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_sweater.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/sweater_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.014] [--hem-drop 0.30] \
|
||||
[--rib-period 0.011] [--base-rgb 0.62,0.585,0.545]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import blender_author_offset_shell as base # noqa: E402
|
||||
import blender_author_denim_pants as denim # noqa: E402 (weld + hips probe)
|
||||
import blender_author_buttondown as bd # noqa: E402 (per-pixel bake helpers)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (the reusable seam — override for cardigan/turtleneck variants)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
GARMENT_ID = "sweater_modern"
|
||||
|
||||
SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
"seg_hips",
|
||||
]
|
||||
|
||||
OFFSET_M = 0.014 # knit-weight standoff (tee 12 / hoodie 16)
|
||||
THICKNESS_M = 0.005 # knit cloth thickness (tee 4 / hoodie 6)
|
||||
WRIST_FRAC = 0.94 # fraction of lowerarm kept — cuffs AT the wrist
|
||||
CUFF_PINCH = 0.75 # offset scale at the wrist rim (ribbing cinches)
|
||||
CUFF_LEN_FRAC = 0.18 # ribbed cuff length, fraction of lowerarm length
|
||||
HEM_DROP_FRAC = 0.30 # hem below the spine_01 waistline, fraction of
|
||||
# (waistline - crotch) — hip-length pullover
|
||||
HEM_BAND_FRAC = 0.085 # ribbed hem band, fraction of (collar_z - hem_z)
|
||||
COLLAR_REACH_FRAC = 1.45 # collar region radial reach, x collar_x_abs
|
||||
CUT_R_MUL = 1.05 # crew cut radius, x the neck ring's measured hd_max
|
||||
CREW_RIM_R_MUL = 1.6 # circularize rim verts within this x ring r_med
|
||||
COLLAR_BAND_FRAC = 0.32 # ribbed collar band height below the crew rim,
|
||||
# x neck_len (~2.5 cm on average_m) — anchored to
|
||||
# the MEASURED rim, not collar_z_min, so the band
|
||||
# reads as a crew rib, not a chest yoke
|
||||
|
||||
# Knit paint (luminance detail; absolute mm scaled by the body's shoulder
|
||||
# ratio so the rib gauge reads identical from child to heavy_m).
|
||||
RIB_PERIOD_M = 0.011 # one rib pair (dark+light) around the band
|
||||
RIB_DARK = 0.85 # dark rib stripe multiplier
|
||||
BAND_MUL = 0.94 # overall band shade vs body knit
|
||||
LINE_MUL = 0.70 # border stitch line multiplier
|
||||
LINE_HALF_M = 0.0022 # border stitch line half-width
|
||||
|
||||
# Warm muted base fabric (default tints in the manifest carry the colour;
|
||||
# the albedo carries luma detail + the untinted fallback tone).
|
||||
FABRIC_RGB = (0.620, 0.585, 0.545)
|
||||
FABRIC_NOISE = 0.030
|
||||
ALBEDO_SEED = 2093
|
||||
|
||||
MASK_SIZE = 512
|
||||
ALBEDO_SIZE = 1024
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[sweater] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Landmarks (all bone-proportional; same philosophy as base.derive_thresholds)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def derive_landmarks(armature, thr, crotch_z):
|
||||
bones = armature.data.bones
|
||||
neck = bones.get("neck_01")
|
||||
la_l = bones.get("lowerarm_l")
|
||||
la_r = bones.get("lowerarm_r")
|
||||
spine01 = bones.get("spine_01")
|
||||
ua_l = bones.get("upperarm_l")
|
||||
ua_r = bones.get("upperarm_r")
|
||||
if not all([neck, la_l, la_r, spine01, ua_l, ua_r]):
|
||||
raise RuntimeError("landmark bones missing (neck_01/lowerarm/spine_01)")
|
||||
|
||||
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
s = shoulder_x / base._REF_SHOULDER_X
|
||||
|
||||
def along(b, frac):
|
||||
return b.head_local.x + frac * (b.tail_local.x - b.head_local.x)
|
||||
|
||||
cut_l = along(la_l, WRIST_FRAC) # left arm +x: delete x > cut_l
|
||||
cut_r = along(la_r, WRIST_FRAC) # right arm -x: delete x < cut_r
|
||||
lowerarm_len = abs(la_l.tail_local.x - la_l.head_local.x)
|
||||
wrist_x_abs = (abs(cut_l) + abs(cut_r)) / 2.0
|
||||
|
||||
waist_z = spine01.head_local.z
|
||||
hem_z = waist_z - HEM_DROP_FRAC * (waist_z - crotch_z)
|
||||
|
||||
# Arm rib axis (y, z) — midpoint of the lowerarm bone, shared by both
|
||||
# sides (the rig mirrors in x only).
|
||||
az_y = (la_l.head_local.y + la_l.tail_local.y) / 2.0
|
||||
az_z = (la_l.head_local.z + la_l.tail_local.z) / 2.0
|
||||
|
||||
lm = {
|
||||
"style": s,
|
||||
"neck_y": neck.head_local.y,
|
||||
"neck_len": neck.tail_local.z - neck.head_local.z,
|
||||
"cut_l": cut_l,
|
||||
"cut_r": cut_r,
|
||||
"wrist_x_abs": wrist_x_abs,
|
||||
"cuff_x0": wrist_x_abs - CUFF_LEN_FRAC * lowerarm_len,
|
||||
"arm_axis_yz": (az_y, az_z),
|
||||
"waist_z": waist_z,
|
||||
"crotch_z": crotch_z,
|
||||
"hem_z": hem_z,
|
||||
"collar_reach": COLLAR_REACH_FRAC * thr["collar_x_abs"],
|
||||
"rib_period": RIB_PERIOD_M * s,
|
||||
"line_half": LINE_HALF_M * s,
|
||||
}
|
||||
lm["hem_top"] = hem_z + HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
|
||||
log(f"landmarks: style {s:.3f} wrist cuts ({cut_l:.3f},{cut_r:.3f}) "
|
||||
f"cuff_x0 {lm['cuff_x0']:.3f} hem {hem_z:.3f} (waist {waist_z:.3f}, "
|
||||
f"crotch {crotch_z:.3f}) hem_top {lm['hem_top']:.3f} "
|
||||
f"collar reach {lm['collar_reach']:.3f}")
|
||||
return lm
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: wrist + hem cuts, rim flattening, crew-collar circularization
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def wrist_cut(shell, lm):
|
||||
"""Trim the sleeve tubes at the wrist plane (hoodie/jacket pattern)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts
|
||||
if v.co.x > lm["cut_l"] or v.co.x < lm["cut_r"]]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"wrist cut removed {len(doomed)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def hem_cut(shell, lm):
|
||||
"""Trim the hips continuation below the hem plane (real hip geometry, so
|
||||
the surviving cloth conforms to the body — no invented coverage)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts if v.co.z < lm["hem_z"]]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"hem cut at z={lm['hem_z']:.3f}: removed {len(doomed)} verts")
|
||||
|
||||
|
||||
def crew_cut(shell, thr, lm):
|
||||
"""Cut the neck tube down to crew height (probe-driven, per body).
|
||||
|
||||
The seg_torso_upper neck boundary is a jagged tooth ring: valley at
|
||||
~neck_z + 0.23*neck_len (crew height), teeth up to ~72% of the neck.
|
||||
Measure the ring (valley z, median + max horizontal distance to the neck
|
||||
axis), then delete every vert ABOVE the valley within hd_max * CUT_R_MUL
|
||||
of the axis — the tube and its teeth, and provably not the neck-trap
|
||||
flare shell (probe: flare hd sits well outside hd_max on all bodies).
|
||||
Stores crew_z / crew_r in lm for the flatten pass.
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
|
||||
mid_l = 0.5 * (thr["sleeve_x_abs"] + lm["cut_l"])
|
||||
mid_r = -0.5 * (thr["sleeve_x_abs"] + abs(lm["cut_r"]))
|
||||
z_split = 0.5 * (lm["hem_z"] + thr["collar_z_min"])
|
||||
ring = []
|
||||
for i in boundary:
|
||||
v = bm.verts[i]
|
||||
if v.co.x > mid_l or v.co.x < mid_r or v.co.z <= z_split:
|
||||
continue
|
||||
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
|
||||
if hd <= lm["collar_reach"] * 1.5:
|
||||
ring.append((hd, v.co.z))
|
||||
if not ring:
|
||||
bm.free()
|
||||
raise RuntimeError("no neck boundary ring found for crew cut")
|
||||
lm["crew_z"] = min(z for _, z in ring)
|
||||
lm["crew_r"] = float(np.median([hd for hd, _ in ring]))
|
||||
hd_max = max(hd for hd, _ in ring)
|
||||
r_cut = hd_max * CUT_R_MUL
|
||||
|
||||
doomed = [v for v in bm.verts
|
||||
if v.co.z > lm["crew_z"]
|
||||
and math.hypot(v.co.x, v.co.y - lm["neck_y"]) <= r_cut]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
lm["collar_band_z0"] = lm["crew_z"] - COLLAR_BAND_FRAC * lm["neck_len"]
|
||||
log(f"crew cut: ring valley z={lm['crew_z']:.3f} r_med={lm['crew_r']:.3f} "
|
||||
f"hd_max={hd_max:.3f} -> removed {len(doomed)} tube verts "
|
||||
f"(r_cut {r_cut:.3f}); collar band z0={lm['collar_band_z0']:.3f}")
|
||||
|
||||
|
||||
def flatten_rims_and_collar(shell, thr, lm):
|
||||
"""Clean every open rim (adapted from the denim flatten_open_rims
|
||||
practice; only boundary verts move, weights/UVs ride along).
|
||||
|
||||
wrist rings -> the wrist cut planes (x per side)
|
||||
hem ring -> the hem plane (z)
|
||||
neck ring -> the crew plane (crew_z from crew_cut): verts within
|
||||
crew_r * CREW_RIM_R_MUL of the neck axis circularize to
|
||||
the ring median radius (clean crew rim); flare verts
|
||||
beyond only drop onto the plane — no radial pinch, which
|
||||
would fold cloth on wide-trap bodies.
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
|
||||
mid_l = 0.5 * (thr["sleeve_x_abs"] + lm["cut_l"])
|
||||
mid_r = -0.5 * (thr["sleeve_x_abs"] + abs(lm["cut_r"]))
|
||||
z_split = 0.5 * (lm["hem_z"] + thr["collar_z_min"])
|
||||
|
||||
wrist_n = hem_n = rim_n = flare_n = 0
|
||||
stray = 0
|
||||
for i in boundary:
|
||||
v = bm.verts[i]
|
||||
if v.co.x > mid_l:
|
||||
v.co.x = lm["cut_l"]
|
||||
wrist_n += 1
|
||||
elif v.co.x < mid_r:
|
||||
v.co.x = lm["cut_r"]
|
||||
wrist_n += 1
|
||||
elif v.co.z <= z_split:
|
||||
v.co.z = lm["hem_z"]
|
||||
hem_n += 1
|
||||
else:
|
||||
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
|
||||
if hd <= lm["crew_r"] * CREW_RIM_R_MUL:
|
||||
if hd > 1e-6:
|
||||
f = lm["crew_r"] / hd
|
||||
v.co.x *= f
|
||||
v.co.y = lm["neck_y"] + (v.co.y - lm["neck_y"]) * f
|
||||
v.co.z = lm["crew_z"]
|
||||
rim_n += 1
|
||||
elif hd <= lm["collar_reach"] * 1.5:
|
||||
v.co.z = lm["crew_z"]
|
||||
flare_n += 1
|
||||
else:
|
||||
stray += 1
|
||||
|
||||
if rim_n == 0:
|
||||
log("WARNING: no crew rim verts found — collar left ragged")
|
||||
log(f"flattened rims: wrist {wrist_n} verts -> cut planes, "
|
||||
f"hem {hem_n} -> z={lm['hem_z']:.3f}, crew rim {rim_n} -> circle "
|
||||
f"r={lm['crew_r']:.3f} z={lm['crew_z']:.3f}, flare {flare_n} -> "
|
||||
f"plane, stray {stray}")
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
|
||||
|
||||
def offset_with_cuff_pinch(shell, offset, lm):
|
||||
"""Outward normal offset with the standoff tapering to CUFF_PINCH over
|
||||
the ribbed cuff band (knit cuffs hug the wrist)."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.normal_update()
|
||||
span = max(lm["wrist_x_abs"] - lm["cuff_x0"], 1e-6)
|
||||
pinched = 0
|
||||
for v in bm.verts:
|
||||
s = 1.0
|
||||
ax = abs(v.co.x)
|
||||
if ax > lm["cuff_x0"]:
|
||||
t = min((ax - lm["cuff_x0"]) / span, 1.0)
|
||||
s = 1.0 - (1.0 - CUFF_PINCH) * t
|
||||
pinched += 1
|
||||
v.co += v.normal * (offset * s)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"offset {offset*1000:.0f} mm outward "
|
||||
f"({pinched} cuff verts pinched to {CUFF_PINCH:.2f}x)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-pixel knit paint + region mask (buttondown bake machinery)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _rib_stripes(arc, period):
|
||||
"""Bool array: dark rib stripe (half of each rib pair)."""
|
||||
return np.mod(arc / period, 1.0) < 0.5
|
||||
|
||||
|
||||
def _classify_px(pos, thr, lm):
|
||||
"""Region mask rows: collar=R, body=G, cuffs+hem=B (spec)."""
|
||||
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
||||
ax = np.abs(x)
|
||||
hd = np.hypot(x, y - lm["neck_y"])
|
||||
collar = (z >= lm["collar_band_z0"]) & (hd <= lm["collar_reach"])
|
||||
band_b = (~collar) & ((ax >= lm["cuff_x0"]) | (z <= lm["hem_top"]))
|
||||
rgba = np.zeros((len(x), 4), dtype=np.float32)
|
||||
rgba[:, 1] = 1.0 # default: body -> G
|
||||
rgba[collar] = (1.0, 0.0, 0.0, 0.0) # crew collar -> R
|
||||
rgba[band_b] = (0.0, 0.0, 1.0, 0.0) # cuffs + hem -> B
|
||||
return rgba
|
||||
|
||||
|
||||
def _paint_px(pos, rows, thr, lm):
|
||||
"""Knit luminance detail: ribbed collar/cuff/hem + border stitch lines."""
|
||||
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
||||
ax = np.abs(x)
|
||||
hd = np.hypot(x, y - lm["neck_y"])
|
||||
period = lm["rib_period"]
|
||||
line = lm["line_half"]
|
||||
|
||||
collar = (z >= lm["collar_band_z0"]) & (hd <= lm["collar_reach"])
|
||||
cuff = (~collar) & (ax >= lm["cuff_x0"])
|
||||
hem = (~collar) & (~cuff) & (z <= lm["hem_top"])
|
||||
|
||||
mul = np.ones(len(x), dtype=np.float32)
|
||||
mul[collar | cuff | hem] = BAND_MUL
|
||||
|
||||
# Collar ribs: azimuth around the neck axis. atan2's +-pi discontinuity
|
||||
# sits where the 2nd arg is negative — (y-cy)*FRONT_Y_SIGN < 0 is the
|
||||
# BACK on this rig, so the rib wrap seam hides at centre-back.
|
||||
arc = np.arctan2(x, (y - lm["neck_y"]) * base.FRONT_Y_SIGN) * hd
|
||||
sel = collar & _rib_stripes(arc, period)
|
||||
mul[sel] *= RIB_DARK
|
||||
|
||||
# Cuff ribs: azimuth around the arm axis (wrap seam under the arm).
|
||||
ay, az_ = lm["arm_axis_yz"]
|
||||
r_arm = np.hypot(y - ay, z - az_)
|
||||
arc = np.arctan2(y - ay, z - az_) * r_arm
|
||||
sel = cuff & _rib_stripes(arc, period)
|
||||
mul[sel] *= RIB_DARK
|
||||
|
||||
# Hem ribs: azimuth around the torso axis (wrap seam at centre-back).
|
||||
r_t = np.hypot(x, y - lm["hem_cy"])
|
||||
arc = np.arctan2(x, (y - lm["hem_cy"]) * base.FRONT_Y_SIGN) * r_t
|
||||
sel = hem & _rib_stripes(arc, period)
|
||||
mul[sel] *= RIB_DARK
|
||||
|
||||
# Border stitch lines at each band's inner edge.
|
||||
mul[(np.abs(z - lm["collar_band_z0"]) <= line)
|
||||
& (hd <= lm["collar_reach"] * 1.1)] = LINE_MUL
|
||||
mul[np.abs(ax - lm["cuff_x0"]) <= line] = LINE_MUL
|
||||
mul[(np.abs(z - lm["hem_top"]) <= line) & (~cuff)] = LINE_MUL
|
||||
|
||||
out = rows * mul[:, None]
|
||||
np.clip(out, 0.0, 1.0, out)
|
||||
return out
|
||||
|
||||
|
||||
def bake_maps(shell, thr, lm, mask_path):
|
||||
"""One pass over UV0 triangles: bake <body>_mask.png + painted albedo."""
|
||||
tris = bd._gather_tris(shell)
|
||||
|
||||
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
|
||||
mbuf[:, :, 1] = 1.0 # body-green background (bilinear-bleed safe)
|
||||
|
||||
rng = np.random.default_rng(ALBEDO_SEED)
|
||||
fabric = np.array(FABRIC_RGB, dtype=np.float32)
|
||||
noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
|
||||
- 0.5) * 2.0 * FABRIC_NOISE
|
||||
abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
||||
|
||||
# Hem torso-axis centre from the final mesh (paint anchor).
|
||||
hem_ys = [v.co.y for v in shell.data.vertices if v.co.z <= lm["hem_top"]]
|
||||
lm["hem_cy"] = float(np.mean(hem_ys)) if hem_ys else 0.0
|
||||
|
||||
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
|
||||
cover = bd._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
|
||||
if cover is not None:
|
||||
pos = bd._interp_pos(cover, co_a, co_b, co_c)
|
||||
mbuf[cover[0], cover[1], :] = _classify_px(pos, thr, lm)
|
||||
cover = bd._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
|
||||
if cover is not None:
|
||||
pos = bd._interp_pos(cover, co_a, co_b, co_c)
|
||||
abuf[cover[0], cover[1], :] = _paint_px(
|
||||
pos, abuf[cover[0], cover[1], :], thr, lm)
|
||||
|
||||
tot = MASK_SIZE * MASK_SIZE
|
||||
log("mask texels: collar={:.1f}% body={:.1f}% cuffs+hem={:.1f}%".format(
|
||||
100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
|
||||
100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
|
||||
100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
|
||||
|
||||
mask_img = bpy.data.images.new(f"{GARMENT_ID}_mask", MASK_SIZE, MASK_SIZE,
|
||||
alpha=True)
|
||||
mask_img.pixels.foreach_set(mbuf.reshape(-1))
|
||||
mask_img.update()
|
||||
mask_img.filepath_raw = mask_path
|
||||
mask_img.file_format = 'PNG'
|
||||
mask_img.save()
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
|
||||
argba = np.concatenate(
|
||||
[abuf, np.ones((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)],
|
||||
axis=2)
|
||||
albedo_img = bpy.data.images.new(f"{GARMENT_ID}_albedo", ALBEDO_SIZE,
|
||||
ALBEDO_SIZE, alpha=False)
|
||||
albedo_img.pixels.foreach_set(argba.reshape(-1))
|
||||
albedo_img.update()
|
||||
return albedo_img
|
||||
|
||||
|
||||
def save_albedo(albedo_img, out_dir, body):
|
||||
"""Sidecar + embed naming (hoodie convention): the glTF exporter names the
|
||||
embedded image after the file basename and Godot extracts it as
|
||||
<glb>_<imagename>.png, so pointing the image at base_albedo.png makes the
|
||||
extraction land exactly on <body>_base_albedo.png."""
|
||||
sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
albedo_img.filepath_raw = sidecar
|
||||
albedo_img.file_format = 'PNG'
|
||||
albedo_img.save()
|
||||
log(f"painted albedo -> {sidecar}")
|
||||
albedo_img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
|
||||
albedo_img.save()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_sweater(body_dir, out_dir, body, offset):
|
||||
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
|
||||
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
thr = base.derive_thresholds(armature)
|
||||
lm = derive_landmarks(armature, thr, crotch_z)
|
||||
|
||||
denim.weld_boundaries(shell)
|
||||
wrist_cut(shell, lm)
|
||||
hem_cut(shell, lm)
|
||||
crew_cut(shell, thr, lm)
|
||||
flatten_rims_and_collar(shell, thr, lm)
|
||||
offset_with_cuff_pinch(shell, offset, lm)
|
||||
base.solidify(shell, THICKNESS_M)
|
||||
|
||||
base.author_logo_uv(shell, thr) # UV2 before bakes (mask/albedo use UV0)
|
||||
albedo_img = bake_maps(shell, thr, lm,
|
||||
os.path.join(out_dir, f"{body}_mask.png"))
|
||||
save_albedo(albedo_img, out_dir, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global OFFSET_M, HEM_DROP_FRAC, RIB_PERIOD_M, FABRIC_RGB
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--hem-drop F] [--rib-period M] "
|
||||
"[--base-rgb r,g,b]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--hem-drop" in argv:
|
||||
HEM_DROP_FRAC = float(argv[argv.index("--hem-drop") + 1])
|
||||
if "--rib-period" in argv:
|
||||
RIB_PERIOD_M = float(argv[argv.index("--rib-period") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
FABRIC_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"per-body sweater: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
|
||||
f"hem-drop {HEM_DROP_FRAC}")
|
||||
|
||||
avg_albedo_stash = os.path.join(out_dir, "_tmp_avg_base_albedo.png")
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_sweater(body_dir, out_dir, body, offset)
|
||||
if body == base.REFERENCE_BODY:
|
||||
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
||||
avg_albedo_stash)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc: # noqa: BLE001 — per-body isolation
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Runtime fallbacks mirror the reference body (average_m).
|
||||
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log("copied reference_mask.png fallback")
|
||||
if os.path.isfile(avg_albedo_stash):
|
||||
shutil.move(avg_albedo_stash, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"base_albedo.png = {base.REFERENCE_BODY}'s painted albedo")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,472 @@
|
||||
"""
|
||||
blender_author_swim_trunks.py (T-1089 wave 2, swim set — swim_trunks)
|
||||
|
||||
Authors bright SWIM TRUNKS as per-body offset shells: hips + upper legs with
|
||||
the hem clearly ABOVE the knee (shorter than shorts_modern's 0.78 thigh
|
||||
fraction), a painted centre-front drawstring (knot + two hanging cords) and a
|
||||
lateral SIDE PANEL colour block down each leg.
|
||||
|
||||
Regions (RGBA mask, toon_garment.gdshader):
|
||||
waistband -> R (tint_0), body -> G (tint_1), side panel -> B (tint_2)
|
||||
|
||||
Companion to blender_author_offset_shell.py (imported as a library — scene
|
||||
build/join, offset, solidify, GLB export). The bottoms-specific practices are
|
||||
REUSED from the proven wave-1 companions, not rediscovered:
|
||||
|
||||
* denim (blender_author_denim_pants.py): boundary WELD of coincident
|
||||
segment-seam rings before offsetting (un-welded rings offset apart along
|
||||
diverging normals -> cracks), open-rim FLATTENING onto clean planes (the
|
||||
segment splitter leaves 4.5-7.6 cm jagged teeth at the waist/hem rims),
|
||||
the feathered --waist-flare deep-crouch mitigation, the post-solidify
|
||||
waist residue clamp, and the parked logo TEXCOORD_1 layer (trunks are not
|
||||
logo-capable, but toon_garment.gdshader samples UV2 unconditionally).
|
||||
* legs (blender_author_offset_shell_legs.py): the bone-plane hem cut.
|
||||
|
||||
Like the denim script, albedo and region mask are painted TEXEL-level from
|
||||
ONE analytic feature-field evaluation per texel (UV0 triangles rasterized
|
||||
with barycentric-interpolated 3D positions), so the painted drawstring/panel
|
||||
and the recolor regions always agree:
|
||||
- albedo: bright saturated default (coral body, teal side panel), painted
|
||||
drawstring knot + hanging cords (centre front), waistband border
|
||||
+ hem border stitches, side-panel edge piping — flat and
|
||||
toon-friendly, identity carried by the texture (style pin:
|
||||
modern only).
|
||||
- mask: waistband R, body G, side panel B.
|
||||
|
||||
All cut/mask/paint parameters derive PER BODY from that body's own bone
|
||||
landmarks (thigh_l/r) and measured mesh extents (waist rim valley, hem
|
||||
plane), scaled by the body's garment span and hip half-width — the same
|
||||
proportional-ratio philosophy as base.derive_thresholds. Per-body mode only
|
||||
(offset shells author per body, Q-060).
|
||||
|
||||
Usage (swim_trunks reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_swim_trunks.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/swim_trunks \
|
||||
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 0.60] \
|
||||
[--band-frac 0.14] [--panel-frac 0.42] [--waist-flare 0.007] \
|
||||
[--base-rgb 0.95,0.45,0.35] [--panel-rgb 0.13,0.68,0.64] [--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the wave-1 modules (shared machinery — reused, not copied)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(mod_name, file_name):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
mod_name, os.path.join(_HERE, file_name))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load("offset_shell_base", "blender_author_offset_shell.py")
|
||||
denim = _load("denim_pants", "blender_author_denim_pants.py")
|
||||
legs = _load("offset_shell_legs", "blender_author_offset_shell_legs.py")
|
||||
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (defaults = swim_trunks)
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"]
|
||||
|
||||
HEM_FRAC = 0.60 # fraction of the thigh bone KEPT below its head.
|
||||
# 0.60 puts the hem ~17 cm above the knee on
|
||||
# average_m — clearly shorter than shorts_modern's
|
||||
# 0.78 (~9 cm above the knee), per the swim spec.
|
||||
TEX_SIZE = 1024 # albedo + mask resolution (painted details need >512)
|
||||
WAIST_FLARE_M = 0.007 # feathered radial stand-off at the waistband rim
|
||||
# (deep-crouch waist-fold mitigation — QA evidence
|
||||
# from the peasant + jeans sets, reused from denim)
|
||||
|
||||
# Vertical proportions — fractions of the garment span (waist_z - hem_z).
|
||||
BAND_FRAC = 0.14 # waistband height (R region) (~4.4 cm on average_m)
|
||||
SEAM_W_FRAC = 0.019 # painted stitch/piping line width (~6 mm on average_m)
|
||||
HEM_STITCH_FRAC = 0.028 # hem border stitch centre height above the hem
|
||||
CORD_LEN_FRAC = 0.20 # drawstring cord drop below the knot (~6.3 cm)
|
||||
|
||||
# Horizontal proportions — fractions of the hip half-width (|thigh head x|).
|
||||
PANEL_HW_FRAC = 0.42 # side-panel half ARC width (~3.8 cm -> 7.6 cm panel)
|
||||
|
||||
# Drawstring metrics in metres on average_m, scaled by the body's hip ratio.
|
||||
KNOT_R_M = 0.009 # knot blob radius
|
||||
CORD_W_M = 0.0055 # cord line width
|
||||
CORD_X0_M = 0.011 # cord |x| offset just below the knot
|
||||
CORD_SLANT = 0.28 # outward drift of the cords per metre of drop
|
||||
|
||||
# Swim style (sRGB floats; saved as-is — matches the proven base pipeline).
|
||||
# Bright saturated default per spec: coral body / teal side panel. Luma of
|
||||
# both stays near the toon_garment.gdshader recolor sweet spot (~0.5-0.6).
|
||||
CORAL_RGB = (0.95, 0.45, 0.35) # body + waistband base (luma ~0.59)
|
||||
TEAL_RGB = (0.13, 0.68, 0.64) # side panel (luma ~0.51)
|
||||
TRIM_RGB = (0.97, 0.95, 0.90) # drawstring + stitches/piping (cream)
|
||||
BAND_SHADE = 0.93 # waistband albedo darkening (reads untinted)
|
||||
ALBEDO_NOISE = 0.020 # +/- woven jitter
|
||||
PLAIN = False # --plain: skip drawstring/stitch/piping paint
|
||||
NOISE_SEED = 3089
|
||||
|
||||
# Reference proportions (average_m) the fractions were calibrated against:
|
||||
# waist rim valley 1.0312, hem(0.60) 0.7139 -> span 0.3173; |thigh head x|.
|
||||
_REF_HIP_X = 0.0906
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TrunkLandmarks:
|
||||
"""Cut/mask/paint parameters derived from one body's bones + mesh."""
|
||||
|
||||
def __init__(self, armature):
|
||||
bones = armature.data.bones
|
||||
thigh_l = bones.get("thigh_l")
|
||||
thigh_r = bones.get("thigh_r")
|
||||
if thigh_l is None or thigh_r is None:
|
||||
raise RuntimeError("thigh_l/thigh_r missing — not the 65-bone rig?")
|
||||
self.hip_x = (abs(thigh_l.head_local.x) + abs(thigh_r.head_local.x)) / 2.0
|
||||
# Hem plane: fraction of the thigh bone kept below its head (averaged
|
||||
# over both sides — they are symmetric on every shipped body).
|
||||
self.hem_z = (
|
||||
thigh_l.head_local.z + HEM_FRAC * (thigh_l.tail_local.z - thigh_l.head_local.z)
|
||||
+ thigh_r.head_local.z + HEM_FRAC * (thigh_r.tail_local.z - thigh_r.head_local.z)
|
||||
) / 2.0
|
||||
# Leg axis control points (z-increasing: knee -> hip) for the azimuth
|
||||
# side-panel placement. x values are the +x (left) leg; the right leg
|
||||
# mirrors via sign.
|
||||
self.leg_z_pts = np.array([thigh_l.tail_local.z, thigh_l.head_local.z])
|
||||
self.leg_x_pts = np.array(
|
||||
[abs(thigh_l.tail_local.x), abs(thigh_l.head_local.x)])
|
||||
self.leg_y_pts = np.array([thigh_l.tail_local.y, thigh_l.head_local.y])
|
||||
log(f"landmarks: hem plane z={self.hem_z:.3f} "
|
||||
f"(thigh head {thigh_l.head_local.z:.3f} -> knee "
|
||||
f"{thigh_l.tail_local.z:.3f}, keep {HEM_FRAC:.2f}) "
|
||||
f"hip_x={self.hip_x:.3f}")
|
||||
|
||||
def finalize(self, waist_plane, hem_plane):
|
||||
"""Derive paint metrics from the CLEAN (flattened) rims."""
|
||||
self.waist_z = waist_plane
|
||||
self.hem_plane = hem_plane
|
||||
self.span = waist_plane - hem_plane
|
||||
self.sh = self.hip_x / _REF_HIP_X
|
||||
self.band_h = BAND_FRAC * self.span
|
||||
self.band_z = waist_plane - self.band_h
|
||||
self.seam_w = SEAM_W_FRAC * self.span
|
||||
self.panel_hw = PANEL_HW_FRAC * self.hip_x
|
||||
self.hem_stitch_z = hem_plane + HEM_STITCH_FRAC * self.span
|
||||
self.knot_z = self.band_z + 0.55 * self.band_h
|
||||
self.knot_r = KNOT_R_M * self.sh
|
||||
self.cord_len = CORD_LEN_FRAC * self.span
|
||||
self.cord_w = CORD_W_M * self.sh
|
||||
self.cord_x0 = CORD_X0_M * self.sh
|
||||
log(f"finalized: waist={self.waist_z:.3f} hem={self.hem_plane:.3f} "
|
||||
f"span={self.span:.3f} band_z={self.band_z:.3f} "
|
||||
f"panel_hw={self.panel_hw * 100:.1f}cm "
|
||||
f"seam_w={self.seam_w * 1000:.1f}mm knot_z={self.knot_z:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Swim feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _trunk_field(px, py, pz, lm):
|
||||
"""Evaluate swim-trunk features at texel 3D positions (numpy arrays).
|
||||
|
||||
Returns bool arrays (in_band, panel, trim):
|
||||
`in_band`/`panel` feed the mask R/B channels; `trim` is every painted
|
||||
cream detail (drawstring, border stitches, panel edge piping).
|
||||
"""
|
||||
w2 = lm.seam_w * 0.5
|
||||
front = py * FRONT_Y_SIGN > 0.004
|
||||
in_band = pz >= lm.band_z
|
||||
mid = ~in_band
|
||||
|
||||
# Per-z leg axis, mirrored by x sign (clamps to the hip values above the
|
||||
# thigh head, so the panel runs straight up through the hip to the band).
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
|
||||
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
r = np.hypot(dx, dy) + 1e-9
|
||||
|
||||
# Side panel: azimuth toward the outer (+/-x) direction; constant metric
|
||||
# width via arc distance (same construction as the denim outseam).
|
||||
arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
|
||||
panel = mid & (arc_out < lm.panel_hw)
|
||||
|
||||
# Painted trim: panel edge piping, waistband border, hem border.
|
||||
piping = mid & (np.abs(arc_out - lm.panel_hw) < w2 * 0.8)
|
||||
wstitch = np.abs(pz - lm.band_z) < w2 * 0.7
|
||||
hstitch = np.abs(pz - lm.hem_stitch_z) < w2 * 0.7
|
||||
|
||||
# Drawstring: knot blob + two cords hanging from it, centre front,
|
||||
# drifting slightly outward as they drop.
|
||||
knot = front & (np.hypot(px, pz - lm.knot_z) < lm.knot_r)
|
||||
drop = np.clip(lm.knot_z - pz, 0.0, None)
|
||||
cord_sep = lm.cord_x0 + CORD_SLANT * drop
|
||||
cords = front & (pz < lm.knot_z) & (pz > lm.knot_z - lm.cord_len) \
|
||||
& (np.abs(np.abs(px) - cord_sep) < lm.cord_w * 0.5)
|
||||
|
||||
trim = piping | wstitch | hstitch | knot | cords
|
||||
return in_band, panel, trim
|
||||
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
in_band, panel, trim = _trunk_field(px, py, pz, lm)
|
||||
|
||||
# --- albedo: coral base, shaded band, teal panel, cream trim ------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = CORAL_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
alb[in_band, :3] *= BAND_SHADE
|
||||
for c in range(3):
|
||||
alb[panel, c] = TEAL_RGB[c] + noise[panel]
|
||||
if not PLAIN:
|
||||
alb[trim, 0] = TRIM_RGB[0]
|
||||
alb[trim, 1] = TRIM_RGB[1]
|
||||
alb[trim, 2] = TRIM_RGB[2]
|
||||
np.clip(alb, 0.0, 1.0, out=alb)
|
||||
|
||||
# --- region mask: waistband R / body G / side panel B --------------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
mask[in_band, 0] = 1.0
|
||||
mask[panel, 2] = 1.0
|
||||
mask[~(in_band | panel), 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the swim field, write albedo + mask together (same rasterizer
|
||||
contract as the denim companion, driving this garment's field)."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = CORAL_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = body green (bilinear-bleed safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
|
||||
total = float(W * H)
|
||||
r_pct = 100.0 * float((mask_buf[:, :, 0] > 0.5).sum()) / total
|
||||
b_pct = 100.0 * float((mask_buf[:, :, 2] > 0.5).sum()) / total
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H}); "
|
||||
f"mask texels: R(waistband)={r_pct:.1f}% B(panel)={b_pct:.1f}% "
|
||||
f"(rest G/background)")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"swim_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"swim_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_trunks_shell(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell)
|
||||
|
||||
lm = TrunkLandmarks(armature)
|
||||
legs.hem_cut(shell, lm.hem_z)
|
||||
waist_plane, hem_plane = denim.flatten_open_rims(shell, lm.hem_z)
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
denim.waist_flare(shell, waist_plane,
|
||||
BAND_FRAC * (waist_plane - hem_plane), WAIST_FLARE_M)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
denim.clamp_waist_residue(shell, waist_plane)
|
||||
|
||||
# Paint metrics reference the CLEAN rims (band under the flattened waist
|
||||
# edge, hem stitch above the flattened hem).
|
||||
lm.finalize(waist_plane, hem_plane)
|
||||
denim.author_parked_uv2(shell)
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global HEM_FRAC, BAND_FRAC, PANEL_HW_FRAC, WAIST_FLARE_M
|
||||
global CORAL_RGB, TEAL_RGB, PLAIN
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--hem-frac F] [--band-frac F] [--panel-frac F] "
|
||||
"[--waist-flare M] [--base-rgb r,g,b] [--panel-rgb r,g,b] "
|
||||
"[--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = base.PER_BODY_OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--hem-frac" in argv:
|
||||
HEM_FRAC = float(argv[argv.index("--hem-frac") + 1])
|
||||
if "--band-frac" in argv:
|
||||
BAND_FRAC = float(argv[argv.index("--band-frac") + 1])
|
||||
if "--panel-frac" in argv:
|
||||
PANEL_HW_FRAC = float(argv[argv.index("--panel-frac") + 1])
|
||||
if "--waist-flare" in argv:
|
||||
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
CORAL_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
if "--panel-rgb" in argv:
|
||||
TEAL_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--panel-rgb") + 1].split(","))
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"swim-trunks per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{offset * 1000:.0f} mm, hem-frac {HEM_FRAC}, band-frac {BAND_FRAC}, "
|
||||
f"panel-frac {PANEL_HW_FRAC}, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_trunks_shell(body_dir, out_dir, body, offset)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,649 @@
|
||||
"""
|
||||
blender_author_swimsuit.py (T-1089 wave 2, swimsuit_onepiece — swim family)
|
||||
|
||||
Authors a one-piece SWIMSUIT as per-body offset shells, reusing
|
||||
blender_author_offset_shell.py as a library (scene build, join, offset,
|
||||
solidify, GLB export) and blender_author_offset_coverall.py's per-texel
|
||||
rasterizer (_tri_texels) for crisp mask/albedo boundaries. Coverage: torso +
|
||||
torso_upper + hips — NO arms, NO legs. The torso_upper is cut down to narrow
|
||||
SHOULDER STRAPS (front scoop + back scoop + open armholes); the bottom gets
|
||||
HIGH-CUT leg openings that rise from the crotch gusset to the hip line at the
|
||||
sides. What this companion adds, as reusable parameters:
|
||||
|
||||
* seg_leg_upper_l/r are INCLUDED in the covered set purely as cut stock:
|
||||
the seg_hips lower boundary is splitter teeth (probe: 9-13 cm jag,
|
||||
z 0.884..1.015 on average_m — worse than the waist teeth denim flattens).
|
||||
Welding the legs in makes that seam interior, so the high-cut leg cut
|
||||
slices through CLEAN thigh geometry and no natural teeth survive; the
|
||||
tubes below the cut are deleted whole.
|
||||
* measurement-guarded straps: the strap corridor is proportional
|
||||
(STRAP_X0/X1_FRAC of shoulder |x|) but clamped per body against the
|
||||
MEASURED neck-seam ring (max |x| + guard) and armscye ring (min |x| −
|
||||
guard), because the armscye reaches |x| = 0.79..0.92 x shoulder depending
|
||||
on body fork (probe) and a fixed fraction would slice into the seam.
|
||||
* measurement-derived armhole plane: z_arm = (armscye ring min z) − drop,
|
||||
so the whole jagged arm-seam ring is guaranteed deleted on every body.
|
||||
* analytic high-cut leg surface z_leg(x, y): crotch gusset (below-crotch at
|
||||
|x| < gusset half-width, so the gusset never opens), rising outward to
|
||||
the thigh-head line, with a front/back blend that keeps the seat covered
|
||||
(RISE_BACK < RISE_FRONT). The SAME function drives the cut, the rim
|
||||
flattening and the trim band in the mask, so they always agree.
|
||||
* open-rim FLATTENING onto the analytic lines (denim practice, post-offset):
|
||||
scoop rims -> scoop plane, armhole rims -> z_arm, leg rims -> z_leg,
|
||||
strap side edges -> |x| snapped to the exact strap planes.
|
||||
|
||||
Regions (RGBA mask, toon_garment.gdshader; spec: straps+trim=R, body=G,
|
||||
side color-block=B):
|
||||
R = shoulder straps + edge trim along every opening (scoop/armhole/leg)
|
||||
G = main body fabric
|
||||
B = side color-block panels (normal-gated: |n.x| >= BLOCK_NX_MIN, below the
|
||||
armholes) — the saturated-color-block default lives in the tints.
|
||||
|
||||
Painted albedo: flat per-region luminance + woven noise + dark stitch lines on
|
||||
region boundaries (toon-friendly; identity carried by the texture). A parked
|
||||
logo_uv TEXCOORD_1 layer ships for channel consistency (not logo-capable).
|
||||
|
||||
Per-body mode only (offset shells author per body, Q-060):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_swimsuit.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/swimsuit_onepiece \
|
||||
[--bodies average_m,child,...] [--offset 0.012]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# Make the sibling modules importable when Blender runs this file directly.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import blender_author_offset_shell as base # noqa: E402
|
||||
import blender_author_offset_coverall as cov # noqa: E402 (per-texel rasterizer)
|
||||
|
||||
log = base.log
|
||||
FRONT = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper", "seg_hips",
|
||||
# cut stock only — welded in so the hips|leg seam teeth become interior,
|
||||
# then everything below the high-cut line is deleted.
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
]
|
||||
|
||||
WELD_DIST = 5e-4 # boundary weld tolerance (0.5 mm, denim practice)
|
||||
MIN_LOOP_VERTS = 6 # ignore sliver boundary loops when measuring rings
|
||||
|
||||
# Straps — proportional corridor over the shoulder top, clamped per body
|
||||
# against the measured neck ring (outside it) and armscye ring (inside it).
|
||||
STRAP_X0_FRAC = 0.52 # inner strap edge, fraction of shoulder |x|
|
||||
STRAP_X1_FRAC = 0.74 # outer strap edge
|
||||
STRAP_MIN_W_FRAC = 0.10 # minimum acceptable strap width (of shoulder |x|)
|
||||
NECK_GUARD_M = 0.004 # keep this far outside the neck-seam ring
|
||||
ARM_GUARD_M = 0.004 # keep this far inside the armscye ring
|
||||
|
||||
# Necklines — fractions of the spine_01->neck_01 span above spine_01 head.
|
||||
FRONT_SCOOP_FRAC = 0.66 # front neckline (covers the seg_torso top band)
|
||||
BACK_SCOOP_FRAC = 0.54 # back scoop, slightly deeper
|
||||
ARMHOLE_DROP_M_REF = 0.015 # armhole plane below the measured armscye min z
|
||||
|
||||
# High-cut leg openings — all spans derive from (thigh head z − crotch z).
|
||||
GUSSET_HALF_FRAC = 0.50 # crotch gusset half-width, fraction of thigh |x|
|
||||
LEG_OUT_X_FRAC = 1.55 # |x| where the rise reaches its max (of thigh |x|)
|
||||
LEG_RISE_FRONT_FRAC = 1.00 # front/side rise: up to the thigh-head line
|
||||
LEG_RISE_BACK_FRAC = 0.58 # back rise: lower, keeps the seat covered
|
||||
LEG_RISE_POW = 1.35 # >1 = convex sweep (classic high-cut)
|
||||
LEG_YBLEND_FRAC = 0.35 # front/back blend half-width (of thigh |x|)
|
||||
GUSSET_DROP_M_REF = 0.004 # gusset cut sits below the crotch -> never opens
|
||||
|
||||
# Region mask / painted albedo.
|
||||
TRIM_W_M_REF = 0.016 # edge-trim band width along the openings
|
||||
BLOCK_NX_MIN = 0.74 # side panel: |normal.x| threshold (normal-gated)
|
||||
ALBEDO_LUMA = {"body": 0.62, "trim": 0.56, "block": 0.67}
|
||||
STITCH_LUMA = 0.30
|
||||
ALBEDO_NOISE = 0.02
|
||||
NOISE_SEED = 3089
|
||||
|
||||
LABELS = ["body", "trim", "block"]
|
||||
LABEL_ID = {name: i for i, name in enumerate(LABELS)}
|
||||
LABEL_RGBA_ARR = np.array(
|
||||
[
|
||||
(0.0, 1.0, 0.0, 0.0), # body -> G
|
||||
(1.0, 0.0, 0.0, 0.0), # trim -> R (straps + edge trim)
|
||||
(0.0, 0.0, 1.0, 0.0), # block -> B (side color-block)
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
LABEL_LUMA_ARR = np.array([ALBEDO_LUMA[n] for n in LABELS], dtype=np.float32)
|
||||
|
||||
_REF_SHOULDER_X = 0.1919 # average_m upperarm head |x| (same anchor as base)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks + measured rings
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class SuitLandmarks:
|
||||
"""Cut/mask parameters from one body's bones, crotch probe and rings."""
|
||||
|
||||
def __init__(self, armature, crotch_z):
|
||||
bones = armature.data.bones
|
||||
|
||||
def bone(name):
|
||||
b = bones.get(name)
|
||||
if b is None:
|
||||
raise RuntimeError(f"landmark bone {name} missing")
|
||||
return b
|
||||
|
||||
ua_l, ua_r = bone("upperarm_l"), bone("upperarm_r")
|
||||
self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
self.scale = self.shoulder_x / _REF_SHOULDER_X
|
||||
neck = bone("neck_01")
|
||||
spine01 = bone("spine_01")
|
||||
span = neck.head_local.z - spine01.head_local.z
|
||||
self.scoop_front = spine01.head_local.z + FRONT_SCOOP_FRAC * span
|
||||
self.scoop_back = spine01.head_local.z + BACK_SCOOP_FRAC * span
|
||||
|
||||
thigh = bone("thigh_l")
|
||||
self.thigh_x = abs(thigh.head_local.x)
|
||||
self.thigh_z = thigh.head_local.z
|
||||
self.crotch_z = crotch_z
|
||||
self.rise_span = self.thigh_z - self.crotch_z
|
||||
self.gx = GUSSET_HALF_FRAC * self.thigh_x
|
||||
self.out_x = LEG_OUT_X_FRAC * self.thigh_x
|
||||
self.yb = LEG_YBLEND_FRAC * self.thigh_x
|
||||
self.gusset_drop = GUSSET_DROP_M_REF * self.scale
|
||||
self.trim_w = TRIM_W_M_REF * self.scale
|
||||
|
||||
# Filled by apply_ring_measurements():
|
||||
self.sx0 = STRAP_X0_FRAC * self.shoulder_x
|
||||
self.sx1 = STRAP_X1_FRAC * self.shoulder_x
|
||||
self.z_arm = self.scoop_front # placeholder until rings are measured
|
||||
|
||||
def apply_ring_measurements(self, neck_max_ax, arm_min_ax, arm_min_z):
|
||||
self.sx0 = max(STRAP_X0_FRAC * self.shoulder_x,
|
||||
neck_max_ax + NECK_GUARD_M * self.scale)
|
||||
self.sx1 = min(STRAP_X1_FRAC * self.shoulder_x,
|
||||
arm_min_ax - ARM_GUARD_M * self.scale)
|
||||
min_w = STRAP_MIN_W_FRAC * self.shoulder_x
|
||||
if self.sx1 - self.sx0 < min_w:
|
||||
log(f"WARNING: strap corridor pinched "
|
||||
f"({(self.sx1 - self.sx0) * 1000:.1f} mm) — widening inward")
|
||||
self.sx0 = max(neck_max_ax + NECK_GUARD_M * self.scale,
|
||||
self.sx1 - min_w)
|
||||
self.z_arm = arm_min_z - ARMHOLE_DROP_M_REF * self.scale
|
||||
log(f"landmarks: straps |x|=[{self.sx0:.3f},{self.sx1:.3f}] "
|
||||
f"scoop_f={self.scoop_front:.3f} scoop_b={self.scoop_back:.3f} "
|
||||
f"z_arm={self.z_arm:.3f} crotch={self.crotch_z:.3f} "
|
||||
f"thigh_z={self.thigh_z:.3f} gusset<|x|<{self.gx:.3f} "
|
||||
f"trim={self.trim_w * 1000:.1f}mm")
|
||||
|
||||
# ---- analytic surfaces (numpy-vectorised; scalars work too) ----------
|
||||
|
||||
def z_leg(self, x, y):
|
||||
"""High-cut leg-opening surface: gusset floor below the crotch,
|
||||
rising outward to the thigh-head line; back rises less (seat)."""
|
||||
t = np.clip((np.abs(x) - self.gx) / max(self.out_x - self.gx, 1e-6),
|
||||
0.0, 1.0) ** LEG_RISE_POW
|
||||
f = np.clip((np.asarray(y) * FRONT + self.yb) / (2.0 * self.yb),
|
||||
0.0, 1.0)
|
||||
rise = self.rise_span * (
|
||||
LEG_RISE_BACK_FRAC + (LEG_RISE_FRONT_FRAC - LEG_RISE_BACK_FRAC) * f)
|
||||
return (self.crotch_z - self.gusset_drop) + t * rise
|
||||
|
||||
def scoop(self, y):
|
||||
"""Neckline level: front scoop on the front side, back scoop behind."""
|
||||
return np.where(np.asarray(y) * FRONT > 0.0,
|
||||
self.scoop_front, self.scoop_back)
|
||||
|
||||
|
||||
def probe_crotch(body_dir):
|
||||
"""Import seg_hips alone to measure the crotch (its lowest point) exactly
|
||||
(denim practice — the joined mesh's min z is the knee cut stock)."""
|
||||
base.clear_scene()
|
||||
objs = base.import_glb(os.path.join(body_dir, "seg_hips.glb"))
|
||||
zs = []
|
||||
for o in objs:
|
||||
if base.is_body_mesh(o):
|
||||
zs.extend(v.co.z for v in o.data.vertices)
|
||||
if not zs:
|
||||
raise RuntimeError("seg_hips.glb yielded no skinned mesh")
|
||||
return min(zs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: weld, ring measurement, cuts, rim flattening
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_boundaries(shell):
|
||||
"""Merge coincident segment-boundary verts so the offset can't open cracks
|
||||
(weights/UVs identical on coincident verts, so skinning is unaffected)."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"welded segment boundaries: {before} -> {len(me.vertices)} verts")
|
||||
|
||||
|
||||
def _boundary_loops(bm):
|
||||
"""Connected open-boundary loops as lists of vert indices."""
|
||||
adj = {}
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) != 1:
|
||||
continue
|
||||
a, b = e.verts[0].index, e.verts[1].index
|
||||
adj.setdefault(a, set()).add(b)
|
||||
adj.setdefault(b, set()).add(a)
|
||||
seen = set()
|
||||
loops = []
|
||||
for start in adj:
|
||||
if start in seen:
|
||||
continue
|
||||
stack, comp = [start], []
|
||||
while stack:
|
||||
v = stack.pop()
|
||||
if v in seen:
|
||||
continue
|
||||
seen.add(v)
|
||||
comp.append(v)
|
||||
stack.extend(adj[v] - seen)
|
||||
loops.append(comp)
|
||||
return loops
|
||||
|
||||
|
||||
def measure_rings(shell, lm):
|
||||
"""Locate the neck-seam and armscye boundary rings on the welded shell.
|
||||
|
||||
Expected loops: neck ring, 2 armscye rings, 2 knee rings (cut stock;
|
||||
median z below the crotch — ignored). Sliver loops (< MIN_LOOP_VERTS)
|
||||
are skipped. Returns (neck_max_ax, arm_min_ax, arm_min_z).
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
candidates = []
|
||||
for comp in _boundary_loops(bm):
|
||||
if len(comp) < MIN_LOOP_VERTS:
|
||||
continue
|
||||
zs = sorted(bm.verts[i].co.z for i in comp)
|
||||
med_z = zs[len(zs) // 2]
|
||||
if med_z < lm.crotch_z:
|
||||
continue # knee ring on the leg cut stock
|
||||
axs = [abs(bm.verts[i].co.x) for i in comp]
|
||||
candidates.append({
|
||||
"med_ax": sorted(axs)[len(axs) // 2],
|
||||
"max_ax": max(axs),
|
||||
"min_ax": min(axs),
|
||||
"min_z": min(bm.verts[i].co.z for i in comp),
|
||||
"n": len(comp),
|
||||
})
|
||||
bm.free()
|
||||
if len(candidates) < 3:
|
||||
raise RuntimeError(
|
||||
f"expected neck + 2 armscye rings, found {len(candidates)}")
|
||||
candidates.sort(key=lambda c: c["med_ax"])
|
||||
neck = candidates[0]
|
||||
arms = candidates[-2:]
|
||||
arm_min_ax = min(a["min_ax"] for a in arms)
|
||||
arm_min_z = min(a["min_z"] for a in arms)
|
||||
log(f"rings: neck max|x|={neck['max_ax']:.3f} ({neck['n']}v) "
|
||||
f"armscye min|x|={arm_min_ax:.3f} min z={arm_min_z:.3f}")
|
||||
return neck["max_ax"], arm_min_ax, arm_min_z
|
||||
|
||||
|
||||
def suit_cuts(shell, lm):
|
||||
"""Delete everything the swimsuit doesn't cover:
|
||||
- neckline scoops between the straps (|x| < sx0, z above scoop level)
|
||||
- armholes outside the straps (|x| > sx1, z above the armhole plane)
|
||||
- legs below the analytic high-cut surface z_leg(x, y)
|
||||
The strap corridor (sx0 <= |x| <= sx1) survives over the shoulder."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
doomed = []
|
||||
for v in bm.verts:
|
||||
x, y, z = v.co.x, v.co.y, v.co.z
|
||||
ax = abs(x)
|
||||
if z < float(lm.z_leg(x, y)):
|
||||
doomed.append(v)
|
||||
elif ax < lm.sx0 and z > float(lm.scoop(y)):
|
||||
doomed.append(v)
|
||||
elif ax > lm.sx1 and z > lm.z_arm:
|
||||
doomed.append(v)
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"suit cuts removed {len(doomed)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def flatten_rims(shell, lm):
|
||||
"""Pull every open rim onto its analytic line (post-offset, denim
|
||||
practice): leg rims -> z_leg surface, scoop rims -> scoop plane, armhole
|
||||
rims -> z_arm plane, strap side edges -> |x| snapped to the strap planes.
|
||||
Only boundary verts move; weights/UVs ride along.
|
||||
|
||||
The leg/top split plane sits MID-GAP between the thigh line (the leg
|
||||
rims' analytic maximum) and the lowest top opening (armhole plane or
|
||||
back scoop) — a dead zone with no legitimate boundary verts. A tight
|
||||
margin (thigh_z + 2 cm) is NOT enough: the 12 mm outward offset runs
|
||||
before flattening and drifts the high-cut side-apex verts upward, and on
|
||||
thin_f two thigh-weighted apex verts crossed it, were classified as
|
||||
armhole rim and teleported ~36 cm up to z_arm — QA showed them as sliver
|
||||
spikes off the seat in deep Crouch_Fwd (worst vert-from-centroid 294 mm)."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
snap_eps = 0.006 * lm.scale
|
||||
leg_z_max = 0.5 * (lm.thigh_z + min(lm.z_arm, lm.scoop_back))
|
||||
counts = {"leg": 0, "scoop": 0, "armhole": 0, "strap": 0}
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
for i in boundary:
|
||||
v = bm.verts[i]
|
||||
x, y, z = v.co.x, v.co.y, v.co.z
|
||||
ax = abs(x)
|
||||
if z < leg_z_max:
|
||||
v.co.z = float(lm.z_leg(x, y))
|
||||
counts["leg"] += 1
|
||||
elif ax < lm.sx0 - snap_eps:
|
||||
v.co.z = float(lm.scoop(y))
|
||||
counts["scoop"] += 1
|
||||
elif ax > lm.sx1 + snap_eps:
|
||||
v.co.z = lm.z_arm
|
||||
counts["armhole"] += 1
|
||||
else:
|
||||
edge = lm.sx0 if abs(ax - lm.sx0) <= abs(ax - lm.sx1) else lm.sx1
|
||||
v.co.x = edge if x >= 0.0 else -edge
|
||||
counts["strap"] += 1
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log("flattened rims: " + " ".join(f"{k}={v}" for k, v in counts.items()))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Region classification (per texel — interpolated position + normal)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def classify_texels(pos, nrm, lm):
|
||||
"""Classify N texels. pos/nrm are (N,3) body-local arrays. Returns (N,)
|
||||
uint8 label ids. Precedence: trim (straps + opening edges), block, body."""
|
||||
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
||||
ax = np.abs(x)
|
||||
tw = lm.trim_w
|
||||
scoop = lm.scoop(y)
|
||||
zl = lm.z_leg(x, y)
|
||||
|
||||
strap = (ax >= lm.sx0 - 0.5 * tw) & (ax <= lm.sx1 + 0.5 * tw) \
|
||||
& (z >= scoop - tw)
|
||||
scoop_trim = (ax < lm.sx0) & (z >= scoop - tw)
|
||||
arm_trim = (ax > lm.sx1) & (z >= lm.z_arm - tw)
|
||||
leg_trim = z <= zl + tw
|
||||
trim = strap | scoop_trim | arm_trim | leg_trim
|
||||
|
||||
block = (np.abs(nrm[:, 0]) >= BLOCK_NX_MIN) & (z <= lm.z_arm - tw)
|
||||
|
||||
lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8)
|
||||
lab[block] = LABEL_ID["block"]
|
||||
lab[trim] = LABEL_ID["trim"]
|
||||
return lab
|
||||
|
||||
|
||||
def bake_mask_and_albedo(shell, lm, mask_path, albedo_name, seed):
|
||||
"""One pass over the faces (pre-solidify — exactly one face per texel):
|
||||
bake the RGBA region mask AND the painted albedo (flat per-region
|
||||
luminance + stitch lines on region boundaries + woven noise)."""
|
||||
W = H = base.MASK_SIZE
|
||||
mask = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
|
||||
albedo = np.zeros((H, W, 4), dtype=np.float32)
|
||||
albedo[:, :, 0:3] = ALBEDO_LUMA["body"]
|
||||
albedo[:, :, 3] = 1.0
|
||||
label_map = np.full((H, W), LABEL_ID["body"], dtype=np.uint8)
|
||||
covered = np.zeros((H, W), dtype=bool)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.normal_update()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for bake")
|
||||
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv for loop in loops]
|
||||
pos = [loop.vert.co for loop in loops]
|
||||
nrm = [loop.vert.normal for loop in loops]
|
||||
for i in range(1, len(loops) - 1):
|
||||
tri = (0, i, i + 1)
|
||||
ys, xs, w0, w1, w2 = cov._tri_texels(
|
||||
uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H)
|
||||
if ys.size == 0:
|
||||
continue
|
||||
p = np.empty((ys.size, 3), dtype=np.float32)
|
||||
n = np.empty((ys.size, 3), dtype=np.float32)
|
||||
for axis in range(3):
|
||||
p[:, axis] = (w0 * pos[tri[0]][axis] + w1 * pos[tri[1]][axis]
|
||||
+ w2 * pos[tri[2]][axis])
|
||||
n[:, axis] = (w0 * nrm[tri[0]][axis] + w1 * nrm[tri[1]][axis]
|
||||
+ w2 * nrm[tri[2]][axis])
|
||||
n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9)
|
||||
lab = classify_texels(p, n, lm)
|
||||
label_map[ys, xs] = lab
|
||||
covered[ys, xs] = True
|
||||
mask[ys, xs] = LABEL_RGBA_ARR[lab]
|
||||
albedo[ys, xs, 0:3] = LABEL_LUMA_ARR[lab][:, None]
|
||||
bm.free()
|
||||
total = max(int(covered.sum()), 1)
|
||||
tex_counts = np.bincount(label_map[covered], minlength=len(LABELS))
|
||||
log("region texels: " + " ".join(
|
||||
f"{LABELS[i]}={int(c)} ({100.0 * c / total:.1f}%)"
|
||||
for i, c in enumerate(tex_counts) if c > 0))
|
||||
|
||||
# Woven-feel noise over the fills, before stitch lines (lines stay crisp).
|
||||
rng = np.random.default_rng(seed)
|
||||
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
|
||||
albedo[:, :, 0:3] = np.clip(albedo[:, :, 0:3] + noise, 0.0, 1.0)
|
||||
|
||||
# Stitch lines: label transitions where BOTH texels belong to rasterised
|
||||
# geometry (skipping UV-island borders against background).
|
||||
edge = np.zeros((H, W), dtype=bool)
|
||||
dh = (label_map[:, 1:] != label_map[:, :-1]) & covered[:, 1:] & covered[:, :-1]
|
||||
edge[:, 1:] |= dh
|
||||
edge[:, :-1] |= dh
|
||||
dv = (label_map[1:, :] != label_map[:-1, :]) & covered[1:, :] & covered[:-1, :]
|
||||
edge[1:, :] |= dv
|
||||
edge[:-1, :] |= dv
|
||||
albedo[edge, 0:3] = STITCH_LUMA
|
||||
log(f"stitch lines on {int(edge.sum())} boundary texels")
|
||||
|
||||
# Alpha floor 2/255: keeps Godot's fix_alpha_border import pass a no-op
|
||||
# (channel-packed region data, not transparency — coverall lesson).
|
||||
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
|
||||
|
||||
img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True)
|
||||
img_mask.alpha_mode = 'CHANNEL_PACKED'
|
||||
img_mask.pixels.foreach_set(mask.reshape(-1))
|
||||
img_mask.update()
|
||||
img_mask.filepath_raw = mask_path
|
||||
img_mask.file_format = 'PNG'
|
||||
img_mask.save()
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
|
||||
img_albedo = bpy.data.images.new(f"albedo_{albedo_name}", W, H, alpha=False)
|
||||
img_albedo.pixels.foreach_set(albedo.reshape(-1))
|
||||
img_albedo.update()
|
||||
return img_albedo
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parked logo UV2 (not logo-capable; the shader still samples UV2)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_parked_uv2(shell):
|
||||
me = shell.data
|
||||
while len(me.uv_layers) > 1:
|
||||
me.uv_layers.remove(me.uv_layers[-1])
|
||||
me.uv_layers.new(name="logo_uv")
|
||||
me.uv_layers.active = me.uv_layers[0]
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
uvl = bm.loops.layers.uv.get("logo_uv")
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
loop[uvl].uv = (2.0, 2.0)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log("logo UV2 authored fully parked (not logo-capable)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def purge_strays(shell, armature):
|
||||
"""Drop authoring-scene leftovers before export (defensive hygiene).
|
||||
|
||||
Investigated for T-1089 wave 2: the 'Icosphere' seen when re-importing
|
||||
any garment GLB is NOT file content — the raw glTF JSON contains exactly
|
||||
one mesh (the shell). It is a bone-display widget the Blender IMPORTER
|
||||
fabricates (bone_heuristic), which also leaks one such object into the
|
||||
authoring scene per body-segment import session (base.clear_scene()'s
|
||||
bpy.ops select_all can miss it). Exports were never polluted
|
||||
(use_selection holds), but removing every object that is not the shell
|
||||
or its armature keeps each per-body authoring cycle hermetic. Bone
|
||||
custom_shape references are cleared for the same reason (display-only,
|
||||
no glTF effect)."""
|
||||
cleared = 0
|
||||
for pb in armature.pose.bones:
|
||||
if pb.custom_shape is not None:
|
||||
pb.custom_shape = None
|
||||
cleared += 1
|
||||
strays = [o for o in bpy.data.objects if o not in (shell, armature)]
|
||||
for o in strays:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
if cleared or strays:
|
||||
log(f"purged {len(strays)} stray objects, cleared {cleared} bone "
|
||||
f"custom shapes before export")
|
||||
|
||||
|
||||
def author_swimsuit(body_dir, out_dir, body, offset, seed):
|
||||
crotch_z = probe_crotch(body_dir)
|
||||
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
weld_boundaries(shell)
|
||||
|
||||
lm = SuitLandmarks(armature, crotch_z)
|
||||
lm.apply_ring_measurements(*measure_rings(shell, lm))
|
||||
|
||||
suit_cuts(shell, lm)
|
||||
base.offset_outward(shell, offset)
|
||||
flatten_rims(shell, lm)
|
||||
|
||||
# Bake + UV2 BEFORE solidify: the inner shell duplicates every face with
|
||||
# the same atlas UVs but flipped normals — pre-solidify there is exactly
|
||||
# one face per texel (coverall lesson; the block region is normal-gated).
|
||||
author_parked_uv2(shell)
|
||||
albedo_img = bake_mask_and_albedo(
|
||||
shell, lm, os.path.join(out_dir, f"{body}_mask.png"), body, seed)
|
||||
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
# Shared-name albedo before export so the GLB-embedded texture extracts
|
||||
# to the <body>_base_albedo.png convention (coverall lesson).
|
||||
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||||
purge_strays(shell, armature)
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
||||
os.path.join(out_dir, f"{body}_base_albedo.png"))
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] [--offset M]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = base.PER_BODY_OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"swimsuit per-body mode: {len(bodies)} bodies, "
|
||||
f"offset {offset * 1000:.0f} mm")
|
||||
|
||||
results = []
|
||||
for i, body in enumerate(bodies):
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_swimsuit(body_dir, out_dir, body, offset, seed=NOISE_SEED + i)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Runtime fallback + shared sidecars mirror the reference body.
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,565 @@
|
||||
"""
|
||||
blender_author_tank_top.py (T-1089 wave 2, tank_top — per-body offset shell)
|
||||
|
||||
Sleeveless tee authored per body via the offset-shell route. Imports
|
||||
blender_author_offset_shell.py as the shared library (scene build, join,
|
||||
offset, solidify, logo UV2, export), blender_author_denim_pants.py for the
|
||||
required boundary-WELD practice, and blender_author_buttondown.py for the
|
||||
per-pixel bake machinery (_gather_tris/_tri_cover/_interp_pos). What the tank
|
||||
adds beyond the tee base, as reusable parameters:
|
||||
|
||||
* coverage is torso + torso_upper ONLY — no arm segments. The armhole is a
|
||||
real CUT at the shoulder: verts outboard of the strap (|x| > strap_out)
|
||||
and above the underarm plane are deleted, leaving a shoulder strap between
|
||||
the neck scoop and the armhole.
|
||||
* scoop NECKLINE cut — front scoop lower than the back, blended smoothly
|
||||
across the +-y transition, both guaranteed below the jagged natural neck
|
||||
ring (the segment splitter's 4.5-7.6 cm teeth) so no natural boundary
|
||||
survives except the hem.
|
||||
* ring-swallowing thresholds — the natural neck ring and shoulder/arm rings
|
||||
are MEASURED per body (open-boundary probe after weld) and the strap /
|
||||
scoop / underarm cut planes are clamped so every jagged ring vert falls in
|
||||
the deleted zone. Proportional defaults derive from bone landmarks exactly
|
||||
like base.derive_thresholds.
|
||||
* open-rim treatment (denim practice, adapted): the hem ring is FLATTENED to
|
||||
a clean plane (the ring's deepest tooth, so the hem overlaps a pants
|
||||
waistband); the scoop + armhole cut edges are Laplacian-SMOOTHED along the
|
||||
boundary loops into fair curves (a plane can't represent a curved scoop).
|
||||
* region mask is distance-to-opening based: texels within TRIM_W of the
|
||||
neckline edge -> collar band (R), within TRIM_W of an armhole edge ->
|
||||
armhole trim (B), everything else -> body (G). The painted albedo shares
|
||||
the same distance fields (binding bands + stitch lines + hem stitch), so
|
||||
mask and albedo always agree.
|
||||
* logo-capable chest UV2 — base.author_logo_uv with the chest box rescaled
|
||||
(LOGO_SCALE, aspect preserved) and dropped below the front scoop so the
|
||||
decal never crosses the neckline.
|
||||
|
||||
Region convention (spec): collar band=R (tint_0), body=G (tint_1),
|
||||
armhole trim=B (tint_2). A unused. Bright default tints live in the manifest;
|
||||
the albedo is a bright neutral so tints carry the colour (style pin: modern,
|
||||
texture carries identity, flat toon-friendly).
|
||||
|
||||
Run (per-body only — offset shells author per body, Q-060):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_tank_top.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/tank_top \
|
||||
[--bodies average_m,child,...] [--offset 0.012] \
|
||||
[--front-scoop-frac 0.28] [--strap-out-frac 0.80] [--trim-w 0.020]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb + <out_dir>/<body>_mask.png
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
|
||||
(per-body authoring for offset shells).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import blender_author_offset_shell as base # noqa: E402
|
||||
import blender_author_buttondown as bdn # noqa: E402 (per-pixel bake helpers)
|
||||
import blender_author_denim_pants as denim # noqa: E402 (boundary weld)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Garment parameters (average_m metres; scaled per body by bone landmarks)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper"]
|
||||
|
||||
OFFSET_M = 0.012 # snug per-body standoff (Q-060 ideal)
|
||||
CLOTH_THICKNESS_M = 0.004 # jersey-weight cloth, same as the tee
|
||||
|
||||
# Cut proportions. x fractions are of shoulder |x| (upperarm head); scoop
|
||||
# drops are fractions of the spine_01->neck_01 span — the same landmark
|
||||
# language as base.derive_thresholds.
|
||||
STRAP_IN_FRAC = 0.54 # inner strap edge |x|
|
||||
STRAP_OUT_FRAC = 0.80 # outer strap edge |x| (armhole starts here)
|
||||
FRONT_SCOOP_DROP_FRAC = 0.28 # front neckline below the neck head
|
||||
BACK_SCOOP_DROP_FRAC = 0.13 # back neckline below the neck head
|
||||
MIN_STRAP_W_M = 0.024 # never squeeze the strap narrower than this
|
||||
RING_MARGIN_M = 0.008 # clearance under/inside a measured jagged ring
|
||||
SCOOP_BLEND_Y_M = 0.020 # front->back scoop height blend half-width
|
||||
|
||||
# Open-rim treatment.
|
||||
SMOOTH_ITERS = 12 # Laplacian passes on scoop/armhole boundary loops
|
||||
SMOOTH_LAM = 0.5
|
||||
HEM_RING_SPAN_FRAC = 0.15 # boundary verts below spine_lo + f*span = hem ring
|
||||
|
||||
# Painted trim (distances measured to the opening edges, post-offset).
|
||||
TRIM_W_M = 0.020 # collar / armhole binding band width
|
||||
STITCH_W_M = 0.0028 # stitch line half-width
|
||||
HEM_STITCH_UP_M = 0.010 # hem stitch line height above the hem plane
|
||||
LOGO_SCALE = 0.85 # chest box rescale (aspect preserved)
|
||||
LOGO_TOP_GAP_M = 0.020 # logo box top below the front scoop
|
||||
|
||||
MASK_SIZE = 512
|
||||
ALBEDO_SIZE = 1024
|
||||
|
||||
# Bright neutral jersey — the manifest default tints carry the actual colour.
|
||||
FABRIC_RGB = (0.70, 0.71, 0.73)
|
||||
FABRIC_NOISE = 0.025
|
||||
TRIM_MUL = 0.90 # binding bands read slightly denser than the body
|
||||
STITCH_MUL = 0.55 # dark stitch lines
|
||||
ALBEDO_SEED = 1094 # deterministic, distinct from other garments
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[tank-top] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Landmarks + ring probe + cut parameter derivation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TankLandmarks:
|
||||
"""Bone landmarks the proportional parameters scale from."""
|
||||
|
||||
def __init__(self, armature):
|
||||
bones = armature.data.bones
|
||||
ua_l = bones.get("upperarm_l")
|
||||
ua_r = bones.get("upperarm_r")
|
||||
neck = bones.get("neck_01")
|
||||
spine01 = bones.get("spine_01")
|
||||
if not all([ua_l, ua_r, neck, spine01]):
|
||||
raise RuntimeError("landmark bones missing — not the 65-bone rig?")
|
||||
self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
self.neck_z = neck.head_local.z
|
||||
self.spine_lo = spine01.head_local.z
|
||||
self.span = self.neck_z - self.spine_lo
|
||||
self.scale = self.shoulder_x / base._REF_SHOULDER_X
|
||||
log(f"landmarks: shoulder_x={self.shoulder_x:.4f} neck_z={self.neck_z:.4f} "
|
||||
f"spine_lo={self.spine_lo:.4f} span={self.span:.4f} scale={self.scale:.3f}")
|
||||
|
||||
|
||||
def probe_rings(shell, lm):
|
||||
"""Measure the natural open-boundary rings of the welded torso shell.
|
||||
|
||||
The segment splitter cuts along weight thresholds, so all three natural
|
||||
boundaries (neck ring, arm rings, hem ring) are jagged teeth. The cut
|
||||
thresholds below are clamped so the neck + arm rings fall entirely inside
|
||||
the deleted zone; the hem ring is flattened to a plane instead.
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
bverts = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
bverts.update(v.index for v in e.verts)
|
||||
hem_test_z = lm.spine_lo + HEM_RING_SPAN_FRAC * lm.span
|
||||
hem, neck, arm = [], [], []
|
||||
for i in bverts:
|
||||
co = bm.verts[i].co
|
||||
if co.z < hem_test_z:
|
||||
hem.append(co.copy())
|
||||
elif abs(co.x) < 0.5 * lm.shoulder_x:
|
||||
neck.append(co.copy())
|
||||
else:
|
||||
arm.append(co.copy())
|
||||
bm.free()
|
||||
if not hem or not neck or not arm:
|
||||
raise RuntimeError(
|
||||
f"ring probe incomplete: hem={len(hem)} neck={len(neck)} arm={len(arm)}")
|
||||
rings = {
|
||||
"hem_min_z": min(c.z for c in hem),
|
||||
"hem_max_z": max(c.z for c in hem),
|
||||
"hem_test_z": hem_test_z,
|
||||
"neck_max_ax": max(abs(c.x) for c in neck),
|
||||
"neck_min_z": min(c.z for c in neck),
|
||||
"arm_min_ax": min(abs(c.x) for c in arm),
|
||||
"arm_min_z": min(c.z for c in arm),
|
||||
}
|
||||
log(f"rings: hem n={len(hem)} z {rings['hem_min_z']:.3f}..{rings['hem_max_z']:.3f}; "
|
||||
f"neck n={len(neck)} |x|<={rings['neck_max_ax']:.3f} z>={rings['neck_min_z']:.3f}; "
|
||||
f"arm n={len(arm)} |x|>={rings['arm_min_ax']:.3f} z>={rings['arm_min_z']:.3f}")
|
||||
return rings
|
||||
|
||||
|
||||
def derive_cut(lm, rings):
|
||||
"""Cut planes from proportional defaults, clamped to swallow the rings."""
|
||||
strap_out = min(STRAP_OUT_FRAC * lm.shoulder_x,
|
||||
rings["arm_min_ax"] - RING_MARGIN_M)
|
||||
strap_in = max(STRAP_IN_FRAC * lm.shoulder_x,
|
||||
rings["neck_max_ax"] + RING_MARGIN_M)
|
||||
min_w = MIN_STRAP_W_M * lm.scale
|
||||
if strap_out - strap_in < min_w:
|
||||
squeezed = strap_out - min_w
|
||||
floor = rings["neck_max_ax"] + 0.004
|
||||
if squeezed < floor:
|
||||
log(f"WARNING: strap squeezed against the neck ring "
|
||||
f"(in={squeezed:.3f} floor={floor:.3f}) — using floor")
|
||||
squeezed = floor
|
||||
strap_in = squeezed
|
||||
back_scoop = min(lm.neck_z - BACK_SCOOP_DROP_FRAC * lm.span,
|
||||
rings["neck_min_z"] - RING_MARGIN_M)
|
||||
front_scoop = min(lm.neck_z - FRONT_SCOOP_DROP_FRAC * lm.span, back_scoop)
|
||||
armhole_z = rings["arm_min_z"] - RING_MARGIN_M
|
||||
params = {
|
||||
"strap_in": strap_in,
|
||||
"strap_out": strap_out,
|
||||
"strap_mid": 0.5 * (strap_in + strap_out),
|
||||
"front_scoop": front_scoop,
|
||||
"back_scoop": back_scoop,
|
||||
"armhole_z": armhole_z,
|
||||
"hem_plane": rings["hem_min_z"],
|
||||
"hem_test_z": rings["hem_test_z"],
|
||||
}
|
||||
log(f"cut: strap |x| {strap_in:.3f}..{strap_out:.3f}, scoop front {front_scoop:.3f} "
|
||||
f"back {back_scoop:.3f}, armhole z>{armhole_z:.3f}, hem plane {params['hem_plane']:.3f}")
|
||||
return params
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: tank cut + hem flatten + boundary smoothing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _scoop_z(y, params):
|
||||
"""Neckline height at this y — front scoop blended into the back scoop."""
|
||||
t = (y * base.FRONT_Y_SIGN) / SCOOP_BLEND_Y_M * 0.5 + 0.5
|
||||
t = min(max(t, 0.0), 1.0)
|
||||
return params["back_scoop"] + (params["front_scoop"] - params["back_scoop"]) * t
|
||||
|
||||
|
||||
def tank_cut(shell, params):
|
||||
"""Delete the neck scoop and the armholes.
|
||||
|
||||
Neck zone: |x| < strap_in AND z above the (front/back blended) scoop.
|
||||
Armhole zone: |x| > strap_out AND z above the underarm plane.
|
||||
Both thresholds were clamped so the jagged natural neck/arm rings fall
|
||||
entirely inside the deleted zone — the only natural boundary that
|
||||
survives is the hem ring.
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
doomed = []
|
||||
for v in bm.verts:
|
||||
x, y, z = v.co.x, v.co.y, v.co.z
|
||||
ax = abs(x)
|
||||
if ax < params["strap_in"] and z > _scoop_z(y, params):
|
||||
doomed.append(v)
|
||||
elif ax > params["strap_out"] and z > params["armhole_z"]:
|
||||
doomed.append(v)
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"tank cut removed {len(doomed)} verts; {len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def flatten_hem(shell, params):
|
||||
"""Pull the hem ring's jagged teeth onto one clean plane (denim practice).
|
||||
|
||||
The plane sits at the ring's DEEPEST tooth, so the straightened hem keeps
|
||||
overlapping a pants waistband instead of retreating to the shallowest
|
||||
notch (a tank tucks over the waist; extra length is correct here).
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
hem_idx = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
for v in e.verts:
|
||||
if v.co.z < params["hem_test_z"]:
|
||||
hem_idx.add(v.index)
|
||||
for i in hem_idx:
|
||||
bm.verts[i].co.z = params["hem_plane"]
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened hem ring: {len(hem_idx)} verts -> z={params['hem_plane']:.3f}")
|
||||
|
||||
|
||||
def smooth_open_rims(shell, params, iters=None, lam=SMOOTH_LAM):
|
||||
"""Laplacian-relax the scoop + armhole boundary loops into fair curves.
|
||||
|
||||
Vertex-deletion cuts leave sawtooth edges at mesh resolution; a plane
|
||||
flatten (denim) can't express a curved scoop, so each boundary vert is
|
||||
repeatedly pulled toward the midpoint of its two loop neighbours. Hem
|
||||
verts (already on their plane) are pinned; interior verts never move, so
|
||||
weights/UVs are untouched.
|
||||
"""
|
||||
iters = SMOOTH_ITERS if iters is None else iters
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
nbr = {}
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
a, b = e.verts
|
||||
nbr.setdefault(a.index, []).append(b.index)
|
||||
nbr.setdefault(b.index, []).append(a.index)
|
||||
hem_lim = params["hem_plane"] + 0.001
|
||||
movable = [i for i, ns in nbr.items()
|
||||
if len(ns) == 2 and bm.verts[i].co.z > hem_lim]
|
||||
for _ in range(iters):
|
||||
moved = {}
|
||||
for i in movable:
|
||||
n1, n2 = nbr[i]
|
||||
mid = (bm.verts[n1].co + bm.verts[n2].co) * 0.5
|
||||
moved[i] = bm.verts[i].co.lerp(mid, lam)
|
||||
for i, co in moved.items():
|
||||
bm.verts[i].co = co
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"smoothed {len(movable)} scoop/armhole rim verts ({iters} passes)")
|
||||
|
||||
|
||||
def collect_trim_edges(shell, params):
|
||||
"""Opening-edge vert positions (post-offset) for the distance-based trim.
|
||||
|
||||
Returns (neck_pts, arm_pts) float32 arrays. Hem verts are excluded — the
|
||||
hem gets a painted stitch line, not a tinted band (spec: 3 regions).
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
neck, arm = [], []
|
||||
seen = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) != 1:
|
||||
continue
|
||||
for v in e.verts:
|
||||
if v.index in seen:
|
||||
continue
|
||||
seen.add(v.index)
|
||||
if v.co.z < params["hem_test_z"]:
|
||||
continue
|
||||
if abs(v.co.x) < params["strap_mid"]:
|
||||
neck.append(v.co[:])
|
||||
else:
|
||||
arm.append(v.co[:])
|
||||
bm.free()
|
||||
log(f"trim edges: neckline {len(neck)} verts, armholes {len(arm)} verts")
|
||||
return (np.array(neck, dtype=np.float32),
|
||||
np.array(arm, dtype=np.float32))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-pixel bake: region mask + painted albedo share the distance fields
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _min_dist(pos, pts):
|
||||
"""Min euclidean distance from each row of pos (N,3) to the set pts (K,3)."""
|
||||
if pts.size == 0:
|
||||
return np.full(pos.shape[0], np.inf, dtype=np.float32)
|
||||
d2 = ((pos[:, None, :] - pts[None, :, :]) ** 2).sum(axis=2)
|
||||
return np.sqrt(d2.min(axis=1))
|
||||
|
||||
|
||||
def _classify_px(dn, da, trim_w):
|
||||
"""One-hot RGBA rows: collar band R / armhole trim B / body G."""
|
||||
n = dn.shape[0]
|
||||
rgba = np.zeros((n, 4), dtype=np.float32)
|
||||
is_r = (dn < trim_w) & (dn <= da)
|
||||
is_b = (da < trim_w) & (da < dn)
|
||||
rgba[:, 1] = 1.0
|
||||
rgba[is_r] = (1.0, 0.0, 0.0, 0.0)
|
||||
rgba[is_b] = (0.0, 0.0, 1.0, 0.0)
|
||||
return rgba
|
||||
|
||||
|
||||
def _paint_px(pos, rows, dn, da, dims):
|
||||
"""Painted albedo: binding bands, band stitch lines, hem stitch (in-place)."""
|
||||
mul = np.ones(pos.shape[0], dtype=np.float32)
|
||||
d = np.minimum(dn, da)
|
||||
mul[d < dims["trim_w"]] = TRIM_MUL
|
||||
mul[np.abs(d - dims["trim_w"]) < dims["stitch_w"]] = STITCH_MUL
|
||||
hem_line = np.abs(pos[:, 2] - dims["hem_stitch_z"]) < dims["stitch_w"]
|
||||
mul[hem_line] = STITCH_MUL
|
||||
out = rows * mul[:, None]
|
||||
np.clip(out, 0.0, 1.0, out)
|
||||
return out
|
||||
|
||||
|
||||
def bake_tank_maps(shell, lm, params, neck_pts, arm_pts, mask_path, albedo_path):
|
||||
"""One pass over UV0: bake <body>_mask.png + <body>_base_albedo.png."""
|
||||
dims = {
|
||||
"trim_w": TRIM_W_M * lm.scale,
|
||||
"stitch_w": STITCH_W_M * lm.scale,
|
||||
"hem_stitch_z": params["hem_plane"] + HEM_STITCH_UP_M * lm.scale,
|
||||
}
|
||||
tris = bdn._gather_tris(shell)
|
||||
|
||||
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
|
||||
mbuf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
|
||||
|
||||
rng = np.random.default_rng(ALBEDO_SEED)
|
||||
fabric = np.array(FABRIC_RGB, dtype=np.float32)
|
||||
noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
|
||||
- 0.5) * 2.0 * FABRIC_NOISE
|
||||
abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
||||
|
||||
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
|
||||
cover = bdn._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
|
||||
if cover is not None:
|
||||
pos = bdn._interp_pos(cover, co_a, co_b, co_c)
|
||||
dn = _min_dist(pos, neck_pts)
|
||||
da = _min_dist(pos, arm_pts)
|
||||
mbuf[cover[0], cover[1], :] = _classify_px(dn, da, dims["trim_w"])
|
||||
cover = bdn._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
|
||||
if cover is not None:
|
||||
pos = bdn._interp_pos(cover, co_a, co_b, co_c)
|
||||
dn = _min_dist(pos, neck_pts)
|
||||
da = _min_dist(pos, arm_pts)
|
||||
abuf[cover[0], cover[1], :] = _paint_px(
|
||||
pos, abuf[cover[0], cover[1], :], dn, da, dims)
|
||||
|
||||
tot = MASK_SIZE * MASK_SIZE
|
||||
log("mask texels: collar={:.1f}% body={:.1f}% armhole={:.1f}%".format(
|
||||
100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
|
||||
100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
|
||||
100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
|
||||
|
||||
def _save(buf, name, path, alpha):
|
||||
arr = buf
|
||||
if not alpha:
|
||||
arr = np.concatenate(
|
||||
[buf, np.ones(buf.shape[:2] + (1,), dtype=np.float32)], axis=2)
|
||||
img = bpy.data.images.new(name, buf.shape[1], buf.shape[0], alpha=alpha)
|
||||
img.pixels.foreach_set(arr.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
_save(mbuf, "tank_region_mask", mask_path, alpha=True)
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
albedo_img = _save(abuf, "tank_base_albedo", albedo_path, alpha=False)
|
||||
log(f"saved painted albedo -> {albedo_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Author one body
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_tank(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
lm = TankLandmarks(armature)
|
||||
|
||||
denim.weld_boundaries(shell) # required practice: weld seam rings
|
||||
rings = probe_rings(shell, lm)
|
||||
params = derive_cut(lm, rings)
|
||||
|
||||
tank_cut(shell, params)
|
||||
flatten_hem(shell, params)
|
||||
smooth_open_rims(shell, params)
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
neck_pts, arm_pts = collect_trim_edges(shell, params)
|
||||
base.solidify(shell, CLOTH_THICKNESS_M)
|
||||
|
||||
# Logo UV2: chest box rescaled (aspect preserved) and dropped below the
|
||||
# front scoop so the decal sits fully on cloth.
|
||||
thr = base.derive_thresholds(armature)
|
||||
half_w = (thr["chest_x"][1] - thr["chest_x"][0]) * LOGO_SCALE / 2.0
|
||||
box_h = (thr["chest_z"][1] - thr["chest_z"][0]) * LOGO_SCALE
|
||||
top = params["front_scoop"] - LOGO_TOP_GAP_M * lm.scale
|
||||
thr_logo = dict(thr)
|
||||
thr_logo["chest_x"] = (-half_w, half_w)
|
||||
thr_logo["chest_z"] = (top - box_h, top)
|
||||
base.author_logo_uv(shell, thr_logo)
|
||||
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
albedo_img = bake_tank_maps(shell, lm, params, neck_pts, arm_pts,
|
||||
mask_path, albedo_path)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Entry
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
global FRONT_SCOOP_DROP_FRAC, STRAP_OUT_FRAC, TRIM_W_M, FABRIC_RGB
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] [--offset M] "
|
||||
"[--front-scoop-frac F] [--strap-out-frac F] [--trim-w M] "
|
||||
"[--base-rgb r,g,b]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--front-scoop-frac" in argv:
|
||||
FRONT_SCOOP_DROP_FRAC = float(argv[argv.index("--front-scoop-frac") + 1])
|
||||
if "--strap-out-frac" in argv:
|
||||
STRAP_OUT_FRAC = float(argv[argv.index("--strap-out-frac") + 1])
|
||||
if "--trim-w" in argv:
|
||||
TRIM_W_M = float(argv[argv.index("--trim-w") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
FABRIC_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"per-body mode: {len(bodies)} bodies, offset {offset * 1000:.1f} mm, "
|
||||
f"front scoop {FRONT_SCOOP_DROP_FRAC}, strap out {STRAP_OUT_FRAC}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_tank(body_dir, out_dir, body, offset)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc: # noqa: BLE001 — per-body isolation
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,654 @@
|
||||
"""
|
||||
blender_author_track_jacket.py (T-1089 wave 2 — track_jacket, sport family)
|
||||
|
||||
Authors the TRACK JACKET as per-body offset shells, reusing
|
||||
blender_author_offset_shell.py as a library (scene build, segment join,
|
||||
bone-ratio thresholds, offset, solidify, logo UV2, skinned GLB export).
|
||||
Sport-top sibling of blender_author_outerwear.py (jacket_modern); what track
|
||||
kit needs beyond that script is delivered as reusable parameters, not hacks:
|
||||
|
||||
* SLEEVE STRIPES AS THEIR OWN REGION (the recolorable team stripe): two
|
||||
parallel stripes run along the TOP of each sleeve from the shoulder seam
|
||||
to the cuff. Stripe placement uses a per-|x| arm-axis interpolation from
|
||||
the upperarm/lowerarm bone landmarks (the denim script's per-z leg-axis
|
||||
technique rotated onto the arms), with constant-metric arc widths, so the
|
||||
stripes stay parallel on every body.
|
||||
* Region layout per spec: collar=R, body=G, sleeve stripes=B,
|
||||
cuffs + hem band=A (toon_garment.gdshader channel-blends 4 tints).
|
||||
* TEXEL-level combined bake (denim best practice): every UV0 triangle is
|
||||
rasterized once with barycentric-interpolated 3D positions and ONE
|
||||
analytic feature-field evaluation drives BOTH the painted albedo and the
|
||||
region mask, so paint and regions always agree.
|
||||
* Painted albedo identity (luma-carried, survives any tint): full front
|
||||
ZIP (bright teeth dashes + darker placket + edge stitching) running hem
|
||||
-> through the stand collar; STAND COLLAR raised look via texture
|
||||
shading (base seam ring + cast-shadow band under the collar + brighter
|
||||
gradient toward the collar rim — geometry stays stock); ribbed knit cuff
|
||||
+ hem bands with border stitch lines; stripe edge piping.
|
||||
* Required bottoms/footwear practices adopted for this top
|
||||
(blender_author_denim_pants.py): boundary WELD of coincident
|
||||
segment-seam rings before offsetting (elbow/shoulder/chest seams offset
|
||||
as one surface, no gap rings) and open-rim FLATTENING — the segment
|
||||
splitter leaves ~4.5-7.6 cm jagged teeth at the torso-bottom hem ring,
|
||||
and the vert-threshold wrist cut leaves jagged sleeve ends. The hem ring
|
||||
is pulled UP to its own valley (clean elastic hem, no invented
|
||||
coverage) with a post-solidify residue clamp; the wrist rings are pulled
|
||||
OUT to the nominal cut plane (clean cuff edge over real forearm skin).
|
||||
* LEFT-BREAST logo patch (logo-capable): the base UV2 chest box shifts
|
||||
off-centre — the zip owns the centre line — using the outerwear ratios.
|
||||
|
||||
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_track_jacket.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/track_jacket \
|
||||
[--bodies average_m,child,...] [--offset 0.018] [--wrist-keep 0.90]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module (shared machinery)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
|
||||
base = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(base)
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[track-jacket] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters — track_jacket. A sport-top variant should only touch this block.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
GARMENT_ID = "track_jacket"
|
||||
|
||||
SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
]
|
||||
|
||||
OFFSET_M = 0.018 # sport shell drape: between the 16 mm hoodie and the
|
||||
# 22 mm casual jacket (per-body construction
|
||||
# guarantees body clearance either way)
|
||||
CLOTH_THICKNESS_M = 0.005 # tricot/poly shell weight
|
||||
WRIST_KEEP_FRAC = 0.90 # fraction of the lowerarm kept (hands show)
|
||||
CUFF_BAND_FRAC = 0.18 # last fraction of the KEPT forearm = knit cuff (A)
|
||||
WELD_DIST = 5e-4 # boundary-ring weld tolerance (0.5 mm)
|
||||
TEX_SIZE = 1024 # albedo + mask resolution (zip teeth need > 512)
|
||||
|
||||
# Stand collar (outerwear-calibrated: taller + wider than the tee band).
|
||||
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below the neck head
|
||||
COLLAR_X_MULT = 1.15 # slightly wider than the tee band
|
||||
|
||||
# Sleeve stripes — metric widths on average_m, scaled by shoulder ratio.
|
||||
STRIPE_GAP_HALF_M = 0.006 # half-gap between the two stripes (about the top line)
|
||||
STRIPE_W_M = 0.018 # width of each stripe
|
||||
STRIPE_EDGE_W_M = 0.0035 # painted piping line at each stripe border
|
||||
|
||||
# Front zip (hem -> through the collar), slimmer than jacket_modern's placket.
|
||||
ZIP_HALF_FRAC = 0.022 / base._REF_SHOULDER_X # placket half-width / shoulder |x|
|
||||
TEETH_HALF_W = 0.006 # zip teeth strip half-width, metres
|
||||
TEETH_PERIOD = 0.024 # dash period along Z, metres
|
||||
TEETH_DUTY = 0.014 # bright dash length within a period, metres
|
||||
|
||||
# Hem band height as fraction of the garment span (collar_z_min - hem plane).
|
||||
HEM_BAND_FRAC = 0.07
|
||||
RIB_PERIOD_M = 0.009 # knit rib period on cuff/hem bands
|
||||
LINE_W_M = 0.004 # seam / border stitch line half-width
|
||||
|
||||
# Collar raised-look shading.
|
||||
SHADOW_H_FRAC = 0.30 # under-collar cast-shadow band height, x neck_len
|
||||
COLLAR_GRAD = 0.06 # extra luma toward the collar rim (raised read)
|
||||
COLLAR_REACH_MULT = 1.35 # seam/shadow lateral reach, x collar_x_abs
|
||||
|
||||
# Left-breast logo patch (outerwear ratios; centre line belongs to the zip).
|
||||
CHEST_X_LO_FRAC = 0.035 / base._REF_SHOULDER_X
|
||||
CHEST_X_HI_FRAC = 0.115 / base._REF_SHOULDER_X
|
||||
CHEST_Z_LO_FRAC = (1.30 - base._REF_SPINE_LO_Z) / (base._REF_NECK_Z - base._REF_SPINE_LO_Z)
|
||||
CHEST_Z_HI_FRAC = (1.42 - base._REF_SPINE_LO_Z) / (base._REF_NECK_Z - base._REF_SPINE_LO_Z)
|
||||
|
||||
# Painted-albedo luma palette (toon_garment recolors by luma: tint*(luma*1.5+0.2)).
|
||||
BASE_LUMA = 0.55 # tricot shell
|
||||
STRIPE_LUMA = 0.62 # stripes slightly lighter (read under same-tint too)
|
||||
STRIPE_EDGE_LUMA = 0.36 # stripe piping
|
||||
COLLAR_LUMA = 0.57 # stand collar band
|
||||
COLLAR_SEAM_LUMA = 0.30 # collar base seam ring
|
||||
COLLAR_SHADOW_LUMA = 0.42 # cast shadow just under the collar base
|
||||
PLACKET_LUMA = 0.48 # zip placket band
|
||||
ZIP_EDGE_LUMA = 0.34 # placket edge stitching
|
||||
TEETH_LUMA = 0.88 # bright zip teeth dashes
|
||||
TEETH_GAP_LUMA = 0.34 # dark tape between dashes
|
||||
BAND_LUMA = 0.46 # ribbed cuff + hem bands
|
||||
BAND_LINE_LUMA = 0.32 # band border stitch line
|
||||
RIB_DELTA = 0.035 # knit rib darkening within bands
|
||||
ALBEDO_NOISE = 0.02 # woven jitter
|
||||
FABRIC_HUE = np.array([0.97, 0.98, 1.03], dtype=np.float32) # faint cool cast
|
||||
NOISE_SEED = 2094
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Thresholds — base derivation + track-jacket extras from the same armature
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def track_thresholds(armature, wrist_keep):
|
||||
thr = base.derive_thresholds(armature)
|
||||
bones = armature.data.bones
|
||||
neck = bones.get("neck_01")
|
||||
ua_l = bones.get("upperarm_l")
|
||||
ua_r = bones.get("upperarm_r")
|
||||
la_l = bones.get("lowerarm_l")
|
||||
spine01 = bones.get("spine_01")
|
||||
|
||||
if ua_l and ua_r:
|
||||
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
else:
|
||||
shoulder_x = base._REF_SHOULDER_X
|
||||
thr["sh"] = shoulder_x / base._REF_SHOULDER_X
|
||||
|
||||
# Taller, wider stand collar.
|
||||
if neck:
|
||||
neck_len = neck.tail_local.z - neck.head_local.z
|
||||
thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
|
||||
thr["neck_len"] = neck_len
|
||||
else:
|
||||
thr["neck_len"] = base._REF_NECK_LEN
|
||||
thr["collar_x_abs"] = thr["collar_x_abs"] * COLLAR_X_MULT
|
||||
|
||||
# Front zip placket.
|
||||
thr["zip_half_x"] = shoulder_x * ZIP_HALF_FRAC
|
||||
|
||||
# Wrist cut planes + knit cuff start on the lowerarm bones.
|
||||
cut_planes = []
|
||||
cuff_abs = []
|
||||
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
|
||||
b = bones.get(bone_name)
|
||||
if b is None:
|
||||
log(f"WARNING: bone {bone_name} missing — sleeve left full-length")
|
||||
continue
|
||||
head_x = b.head_local.x
|
||||
tail_x = b.tail_local.x
|
||||
cut_x = head_x + wrist_keep * (tail_x - head_x)
|
||||
band_x = head_x + (wrist_keep - CUFF_BAND_FRAC) * (tail_x - head_x)
|
||||
cut_planes.append((sign, cut_x))
|
||||
cuff_abs.append(abs(band_x))
|
||||
log(f"wrist cut {bone_name}: keep |x| up to {cut_x:.3f} "
|
||||
f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {band_x:.3f}")
|
||||
thr["wrist_cut_planes"] = cut_planes
|
||||
thr["cuff_x_abs"] = min(cuff_abs) if cuff_abs else 1e9
|
||||
|
||||
# Per-|x| arm axis (shoulder -> elbow -> wrist) for stripe placement —
|
||||
# the denim per-z leg-axis technique rotated onto the arms. The rig is
|
||||
# x-mirrored, so the left-arm landmarks serve both sides via |x|.
|
||||
if ua_l and la_l:
|
||||
pts = [ua_l.head_local, la_l.head_local, la_l.tail_local]
|
||||
order = np.argsort([abs(p.x) for p in pts])
|
||||
thr["arm_x"] = np.array([abs(pts[i].x) for i in order])
|
||||
thr["arm_y"] = np.array([pts[i].y for i in order])
|
||||
thr["arm_z"] = np.array([pts[i].z for i in order])
|
||||
else:
|
||||
log("WARNING: arm landmark bones missing — stripes disabled")
|
||||
thr["arm_x"] = None
|
||||
|
||||
# Left-breast logo patch (replaces the base full-chest box).
|
||||
thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC)
|
||||
if neck and spine01:
|
||||
spine_lo = spine01.head_local.z
|
||||
span = neck.head_local.z - spine_lo
|
||||
thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span,
|
||||
spine_lo + CHEST_Z_HI_FRAC * span)
|
||||
|
||||
log(f"track thresholds: collar z>={thr['collar_z_min']:.3f} "
|
||||
f"|x|<{thr['collar_x_abs']:.3f} zip half {thr['zip_half_x']:.3f} "
|
||||
f"cuff |x|>={thr['cuff_x_abs']:.3f} sh {thr['sh']:.3f} "
|
||||
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
|
||||
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
|
||||
return thr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: weld + wrist cut + open-rim flattening (denim required practices)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_boundaries(shell):
|
||||
"""Merge coincident segment-boundary verts so the offset can't open cracks."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_DIST)
|
||||
merged = before - len(bm.verts)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"welded segment boundaries: {merged} verts merged "
|
||||
f"({before} -> {len(me.vertices)})")
|
||||
|
||||
|
||||
def wrist_cut(shell, thr):
|
||||
"""Delete forearm verts beyond the wrist plane on each side."""
|
||||
cut_planes = thr.get("wrist_cut_planes", [])
|
||||
if not cut_planes:
|
||||
log("WARNING: no wrist cut planes — sleeves stay full length")
|
||||
return
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = []
|
||||
for v in bm.verts:
|
||||
for sign, thr_x in cut_planes:
|
||||
if sign > 0 and v.co.x > thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
if sign < 0 and v.co.x < thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"wrist cut removed {len(to_delete)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def flatten_open_rims(shell, thr):
|
||||
"""Pull the open boundary rings onto clean planes (pre-offset).
|
||||
|
||||
The segment splitter cuts along weight thresholds, so the torso-bottom
|
||||
hem ring is a jagged ring of teeth (the denim script measured ~4.5-7.6 cm
|
||||
across bodies), and the vert-threshold wrist cut leaves jagged sleeve
|
||||
ends. Three rims are cleaned:
|
||||
|
||||
* WRIST rings (|x| >= sleeve_x_abs, per side): pulled OUT to the
|
||||
nominal bone-plane cut — a clean vertical cuff edge; the forearm
|
||||
skin continues well past the plane, so no clip risk is invented.
|
||||
* HEM ring (bottom of the remaining boundary): pulled UP to its own
|
||||
valley (the shallowest notch) — a clean straight elastic hem without
|
||||
inventing coverage below the authored shell.
|
||||
* NECK ring (top): left natural — every wave-1 top ships the natural
|
||||
neckline boundary and the collar band owns that edge visually.
|
||||
|
||||
Only boundary verts move; weights and loop UVs ride along. Returns the
|
||||
hem plane z (needed for the hem band paint + post-solidify clamp).
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1: # open boundary
|
||||
boundary.update(v.index for v in e.verts)
|
||||
|
||||
sleeve_x = thr["sleeve_x_abs"]
|
||||
cut_by_sign = {sign: cut_x for sign, cut_x in thr.get("wrist_cut_planes", [])}
|
||||
wrist = [i for i in boundary if abs(bm.verts[i].co.x) >= sleeve_x]
|
||||
torso = [i for i in boundary if abs(bm.verts[i].co.x) < sleeve_x]
|
||||
zs = [bm.verts[i].co.z for i in torso]
|
||||
z_mid = (min(zs) + max(zs)) / 2.0
|
||||
hem = [i for i in torso if bm.verts[i].co.z <= z_mid]
|
||||
neck = [i for i in torso if bm.verts[i].co.z > z_mid]
|
||||
|
||||
moved_wrist = 0
|
||||
for i in wrist:
|
||||
v = bm.verts[i]
|
||||
sign = 1 if v.co.x >= 0.0 else -1
|
||||
if sign in cut_by_sign:
|
||||
v.co.x = cut_by_sign[sign]
|
||||
moved_wrist += 1
|
||||
|
||||
hem_lo = min(bm.verts[i].co.z for i in hem)
|
||||
hem_plane = max(bm.verts[i].co.z for i in hem) # the valley (shallowest notch)
|
||||
for i in hem:
|
||||
bm.verts[i].co.z = hem_plane
|
||||
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened open rims: {moved_wrist} wrist verts -> nominal cut planes; "
|
||||
f"hem ring ({len(hem)} verts, teeth {hem_plane - hem_lo:.3f} m) -> "
|
||||
f"z={hem_plane:.3f}; neck ring left natural ({len(neck)} verts)")
|
||||
return hem_plane
|
||||
|
||||
|
||||
def clamp_hem_residue(shell, hem_plane):
|
||||
"""Post-solidify safety clamp: interior tooth verts still below the hem
|
||||
plane (multi-triangle teeth) get squashed onto it."""
|
||||
me = shell.data
|
||||
n = 0
|
||||
for v in me.vertices:
|
||||
if v.co.z < hem_plane:
|
||||
v.co.z = hem_plane
|
||||
n += 1
|
||||
me.update()
|
||||
if n:
|
||||
log(f"clamped {n} residual hem verts -> {hem_plane:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Track feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _paint_texels(X, Y, Z, noise, thr, hem_plane, hem_top):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions.
|
||||
|
||||
ONE feature evaluation drives both outputs (denim practice), so the
|
||||
painted albedo and the region mask always agree.
|
||||
Region priority: cuff/hem band (A) > sleeve stripes (B) > collar (R) > body (G).
|
||||
"""
|
||||
n = X.shape[0]
|
||||
ax = np.abs(X)
|
||||
front = Y * base.FRONT_Y_SIGN > 0.004
|
||||
sh = thr["sh"]
|
||||
|
||||
on_sleeve = ax >= thr["sleeve_x_abs"]
|
||||
in_cuff = ax >= thr["cuff_x_abs"]
|
||||
in_hem = (~on_sleeve) & (Z <= hem_top)
|
||||
czmin = thr["collar_z_min"]
|
||||
in_collar = (~on_sleeve) & (Z >= czmin) & (ax < thr["collar_x_abs"])
|
||||
|
||||
# Sleeve stripes: arc distance from the top line of the per-|x| arm axis.
|
||||
if thr.get("arm_x") is not None:
|
||||
cy = np.interp(ax, thr["arm_x"], thr["arm_y"])
|
||||
cz = np.interp(ax, thr["arm_x"], thr["arm_z"])
|
||||
dy = Y - cy
|
||||
dz = Z - cz
|
||||
r = np.hypot(dy, dz) + 1e-9
|
||||
arc = np.arctan2(np.abs(dy), dz) * r # 0 on the sleeve top line
|
||||
g0 = STRIPE_GAP_HALF_M * sh
|
||||
w = STRIPE_W_M * sh
|
||||
stripe_zone = on_sleeve & ~in_cuff
|
||||
stripe = stripe_zone & (arc >= g0) & (arc <= g0 + w)
|
||||
ew = STRIPE_EDGE_W_M * sh
|
||||
stripe_edge = stripe_zone & (
|
||||
(np.abs(arc - g0) < ew) | (np.abs(arc - (g0 + w)) < ew))
|
||||
else:
|
||||
arc = np.zeros(n, dtype=np.float32)
|
||||
stripe = np.zeros(n, dtype=bool)
|
||||
stripe_edge = np.zeros(n, dtype=bool)
|
||||
|
||||
# --- region mask (collar=R, body=G, stripes=B, cuffs/hem=A) -------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_a = in_cuff | in_hem
|
||||
is_b = stripe & ~is_a
|
||||
is_r = in_collar & ~is_a & ~is_b
|
||||
is_g = ~(is_a | is_b | is_r)
|
||||
mask[is_a, 3] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[is_r, 0] = 1.0
|
||||
mask[is_g, 1] = 1.0
|
||||
|
||||
# --- painted luma detail -------------------------------------------------
|
||||
v = np.full(n, BASE_LUMA, dtype=np.float32)
|
||||
lw = LINE_W_M * sh
|
||||
|
||||
# Stripes + piping.
|
||||
v[stripe] = STRIPE_LUMA
|
||||
v[stripe_edge] = STRIPE_EDGE_LUMA
|
||||
|
||||
# Stand collar raised look: band luma + brighter gradient toward the rim,
|
||||
# cast-shadow band + seam ring at the collar base (texture-carried depth).
|
||||
reach = thr["collar_x_abs"] * COLLAR_REACH_MULT
|
||||
v[in_collar] = COLLAR_LUMA
|
||||
grad = np.clip((Z - czmin) / max(thr["neck_len"], 1e-6), 0.0, 1.0)
|
||||
v[in_collar] += COLLAR_GRAD * grad[in_collar]
|
||||
shadow_h = SHADOW_H_FRAC * thr["neck_len"]
|
||||
under = (~in_collar) & (~on_sleeve) & (ax < reach) \
|
||||
& (Z < czmin) & (Z >= czmin - shadow_h)
|
||||
t = np.clip((czmin - Z) / max(shadow_h, 1e-6), 0.0, 1.0)
|
||||
v[under] = COLLAR_SHADOW_LUMA \
|
||||
+ (BASE_LUMA - COLLAR_SHADOW_LUMA) * t[under]
|
||||
seam = (~on_sleeve) & (np.abs(Z - czmin) < lw) & (ax < reach)
|
||||
v[seam] = COLLAR_SEAM_LUMA
|
||||
|
||||
# Ribbed cuff + hem bands: band luma, knit ribs, border stitch lines.
|
||||
band = in_cuff | in_hem
|
||||
v[band] = BAND_LUMA
|
||||
cuff_rib = in_cuff & (np.mod(arc, RIB_PERIOD_M) < RIB_PERIOD_M * 0.5)
|
||||
hem_rib = in_hem & (np.mod(X, RIB_PERIOD_M) < RIB_PERIOD_M * 0.5)
|
||||
v[cuff_rib | hem_rib] -= RIB_DELTA
|
||||
v[np.abs(ax - thr["cuff_x_abs"]) < lw] = BAND_LINE_LUMA
|
||||
v[(~on_sleeve) & (np.abs(Z - hem_top) < lw)] = BAND_LINE_LUMA
|
||||
|
||||
# Front zip: placket band + edge stitching + dashed teeth, hem -> through
|
||||
# the collar (paint overrides bands/collar luma; regions stay untouched).
|
||||
zh = thr["zip_half_x"]
|
||||
v[front & (ax < zh)] = PLACKET_LUMA
|
||||
v[front & (np.abs(ax - zh) < lw * 0.7)] = ZIP_EDGE_LUMA
|
||||
teeth = front & (ax < TEETH_HALF_W)
|
||||
dash = np.mod(Z, TEETH_PERIOD) < TEETH_DUTY
|
||||
v[teeth & dash] = TEETH_LUMA
|
||||
v[teeth & ~dash] = TEETH_GAP_LUMA
|
||||
|
||||
# --- albedo --------------------------------------------------------------
|
||||
lum = np.clip(v + noise, 0.0, 1.0)
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = np.clip(lum * FABRIC_HUE[c], 0.0, 1.0)
|
||||
alb[:, 3] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, thr,
|
||||
hem_plane, hem_top, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the track feature field, write albedo + mask together."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin],
|
||||
thr, hem_plane, hem_top)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, thr, hem_plane, hem_top, out_dir, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = np.clip(
|
||||
(BASE_LUMA + noise_buf) * FABRIC_HUE[c], 0.0, 1.0)
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = body green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
thr, hem_plane, hem_top, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
# Honest region tally over the whole mask (texel counts, background excl.
|
||||
# impossible — background is body green by design).
|
||||
tot = W * H
|
||||
counts = {
|
||||
"collar(R)": int((mask_buf[:, :, 0] > 0.5).sum()),
|
||||
"body(G)": int((mask_buf[:, :, 1] > 0.5).sum()),
|
||||
"stripes(B)": int((mask_buf[:, :, 2] > 0.5).sum()),
|
||||
"cuff/hem(A)": int((mask_buf[:, :, 3] > 0.5).sum()),
|
||||
}
|
||||
log("mask texels: " + " ".join(
|
||||
f"{k}={v} ({100.0 * v / tot:.1f}%)" for k, v in counts.items()))
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
_save(mask_buf, f"{GARMENT_ID}_mask_{body}",
|
||||
os.path.join(out_dir, f"{body}_mask.png"))
|
||||
albedo_img = _save(alb_buf, f"{GARMENT_ID}_albedo_{body}",
|
||||
os.path.join(out_dir, f"{body}_base_albedo.png"))
|
||||
log(f"saved mask -> {body}_mask.png")
|
||||
log(f"saved albedo -> {body}_base_albedo.png")
|
||||
# Repoint the image at the shared sidecar name before export: the glTF
|
||||
# exporter names the embedded image after the file basename, and Godot
|
||||
# extracts it as <glb>_<imagename>.png -> <body>_base_albedo.png (tshirt
|
||||
# convention). The shared file is re-pointed to average_m's at the end.
|
||||
albedo_img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
|
||||
albedo_img.save()
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_track_jacket(body_dir, out_dir, body, offset, wrist_keep):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
thr = track_thresholds(armature, wrist_keep)
|
||||
|
||||
weld_boundaries(shell)
|
||||
wrist_cut(shell, thr)
|
||||
hem_plane = flatten_open_rims(shell, thr)
|
||||
base.offset_outward(shell, offset)
|
||||
base.solidify(shell, CLOTH_THICKNESS_M)
|
||||
clamp_hem_residue(shell, hem_plane)
|
||||
|
||||
hem_top = hem_plane + HEM_BAND_FRAC * (thr["collar_z_min"] - hem_plane)
|
||||
log(f"hem band: z {hem_plane:.3f}..{hem_top:.3f}")
|
||||
|
||||
base.author_logo_uv(shell, thr) # UV2 before bake (bake reads UV0 directly)
|
||||
albedo_img = paint_albedo_and_mask(shell, thr, hem_plane, hem_top,
|
||||
out_dir, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--wrist-keep F]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
wrist_keep = WRIST_KEEP_FRAC
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--wrist-keep" in argv:
|
||||
wrist_keep = float(argv[argv.index("--wrist-keep") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"track-jacket per-body mode: {len(bodies)} bodies, "
|
||||
f"offset {offset*1000:.0f} mm, wrist keep {wrist_keep:.2f}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_track_jacket(body_dir, out_dir, body, offset, wrist_keep)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "boots_modern", "slot": "feet", "covers": ["leg_lower_l", "leg_lower_r"]}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/boots_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "cargo_pants", "slot": "legs"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/cargo_pants"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "joggers_modern", "slot": "legs"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/joggers_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "parka_thrds", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/parka_thrds"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "shoes_formal", "slot": "feet", "covers": ["foot_l", "foot_r"]}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/shoes_formal"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "slides", "slot": "feet", "covers": ["foot_l", "foot_r"]}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.012,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/slides"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "sneakers_modern", "slot": "feet"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/sneakers_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "sweater_modern", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/sweater_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "swim_trunks", "slot": "legs"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/swim_trunks"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "swimsuit_onepiece", "slot": "full_body"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/swimsuit_onepiece"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "tank_top", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/tank_top"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "track_jacket", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/track_jacket"
|
||||
}
|
||||
Reference in New Issue
Block a user