35 payloads move to tooling/scripts/blender/ and stay outside package scope. They run under Blender's bundled Python, which cannot see the repo venv, so they physically cannot import tooling.core — holding them to the D-263 contract would either fail the gate forever or force the contract to be weakened for everyone, and the second is how a gate stops meaning anything. Count verified by import rather than filename: 33 import bpy/bmesh directly, and the two that do not are still payloads per their own usage lines. garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290. The bash wrapper is retired rather than kept. Keeping it would have put the install-resolution logic in two places, which is the duplication T-1286 had just finished collapsing three copies of. domains/blender/service.py owns the decisions — resolve_blender (native beats flatpak, ordering preserved), resolve_payload, absolutise — and only run_payload performs. test_blender.py pins all of them without launching Blender, which matters here more than usual: the thing being launched is a 200 MB GUI application that writes GLBs. `reach blender run` takes a registered payload name OR a path to any script, because the wrapper served both — the spikes and the glb-gen skill hand it one-off scripts of their own. An unknown name enumerates all 35 and exits 2. The exclusion now defends itself. check_carve_out_stays_carved fails if `scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty exclusion proves nothing), or if an __init__.py appears there (which would make the payloads importable — the coupling the carve-out exists to prevent). All three arms mutation-proved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
888 lines
36 KiB
Python
888 lines
36 KiB
Python
"""
|
|
blender_author_offset_shell.py (T-1089, route (c) offset-shell authoring)
|
|
|
|
Derive a garment SHELL from our own body segment meshes — the "create-stuff-
|
|
yourself" authoring pipeline that owes nothing to any vendor pack. Because the
|
|
shell IS our body topology, bone weights are inherited by construction (no
|
|
Surface-Deform, no Data-Transfer, no re-rig): every vertex keeps the 65-bone
|
|
vertex groups it had as skin.
|
|
|
|
Pipeline (t-shirt reference on average_m):
|
|
1. Import the body segments the garment covers (torso + torso_upper + upper
|
|
arms), keep only the skinned body meshes (Icosphere debris filtered out).
|
|
2. Join into one mesh under a single armature; merge vertex groups by name.
|
|
3. Bone-plane SLEEVE cut — trim the upper-arm tube to short-sleeve length via
|
|
a coordinate threshold derived from the upperarm bone axis (robust; no
|
|
boundary-loop classification, which the segment tool warns is fragile).
|
|
Neckline + hem come free as the natural segment boundaries.
|
|
4. Offset the surface outward along vertex normals (~12 mm standoff from skin).
|
|
5. Solidify (use_rim=True) — gives the cloth real thickness and caps the cut
|
|
rims (sleeve openings) into hems.
|
|
6. Assign ONE flat modern-fabric material (drops all skin textures). Style pin:
|
|
neutral heather tone, no trim, no fantasy anything.
|
|
7. Bake a UV0-aligned RGBA REGION MASK per-face: collar band -> R, main body
|
|
-> G, sleeve trim -> B (channel-routed 4-tint shader input, G3).
|
|
8. Author a 2nd UV channel (TEXCOORD_1) projecting the front chest into [0,1]
|
|
for the logo decal (G4); everything else parks outside the box.
|
|
9. Export the reference GLB (export_skins=True) + write reference_mask.png and
|
|
base_albedo.png sidecars.
|
|
|
|
Two modes (T-1089):
|
|
|
|
SINGLE-REFERENCE (default) — author on one body (average_m); G1
|
|
(blender_batch_fit_skinned.py) SD-fits it to the other body types. Right for
|
|
derived/hand-authored garments that share one UV layout + one mask.
|
|
|
|
tooling/blender --background --python \
|
|
tooling/garment-fit/blender_author_offset_shell.py -- \
|
|
<bodies_dir>/average_m <out_dir> [--offset 0.020] [--sleeve-frac 0.40]
|
|
|
|
Writes:
|
|
<out_dir>/average_m.glb reference garment (skinned)
|
|
<out_dir>/reference_mask.png RGBA region mask (UV0-aligned)
|
|
<out_dir>/base_albedo.png flat fabric albedo (also embedded in the GLB)
|
|
|
|
PER-BODY (--per-body) — author the shell from EACH body's own segment meshes.
|
|
QA evidence (Q-060): single-reference SD-fit of offset-shells degrades with
|
|
girth divergence (muscular_m 859px worst clip at 24 mm standoff). Authoring
|
|
per body gives guaranteed standoff + exact weights by construction, and lets
|
|
the offset drop back to the ~12 mm ideal. Cut/mask parameters are derived
|
|
from each body's OWN bone landmarks using the same proportional ratios
|
|
(anchored to reproduce the hand-calibrated average_m constants exactly), so
|
|
regions stay consistent across bodies.
|
|
|
|
tooling/blender --background --python \
|
|
tooling/garment-fit/blender_author_offset_shell.py -- \
|
|
<bodies_dir> <out_dir> --per-body \
|
|
[--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40]
|
|
|
|
Writes per body:
|
|
<out_dir>/<body>.glb skinned garment authored on that body
|
|
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0 layout)
|
|
Plus:
|
|
<out_dir>/base_albedo.png shared flat fabric albedo (deterministic)
|
|
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
|
|
|
|
The runtime compositor (character_visual.gd) prefers <body>_mask.png and
|
|
falls back to reference_mask.png for SD-fit garments.
|
|
|
|
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import shutil
|
|
import bpy
|
|
import bmesh
|
|
import numpy as np
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Parameters
|
|
# --------------------------------------------------------------------------
|
|
|
|
# Segments a t-shirt covers. Order matters only for join-active choice.
|
|
COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r"]
|
|
|
|
OFFSET_M = 0.020 # single-reference standoff along vertex normals; larger
|
|
# than the 10-14 mm ideal on the reference body buys
|
|
# clearance for bigger bodies under Surface-Deform
|
|
# batch-fit (Q-060) — the muscular/female torso otherwise
|
|
# pokes through.
|
|
PER_BODY_OFFSET_M = 0.012 # per-body standoff — each body's own surface guarantees
|
|
# clearance by construction, so the ideal applies.
|
|
CLOTH_THICKNESS_M = 0.004 # Solidify thickness after offset
|
|
SLEEVE_FRAC = 0.40 # fraction of upper-arm length kept (short sleeve)
|
|
MASK_SIZE = 512
|
|
ALBEDO_SIZE = 512
|
|
|
|
# All shipping body types (matches blender_batch_fit_skinned.py / the runtime).
|
|
BODY_TYPES = [
|
|
"average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f",
|
|
"heavy_m", "heavy_f", "teen_m", "teen_f", "child",
|
|
]
|
|
REFERENCE_BODY = "average_m"
|
|
|
|
# Flat modern-fabric base tone (linear-ish sRGB), neutral heather grey.
|
|
FABRIC_RGB = (0.60, 0.61, 0.63)
|
|
FABRIC_NOISE = 0.03 # +/- albedo jitter for a subtle woven feel
|
|
|
|
# Chest logo box in body-local metres (X width, Z height).
|
|
# FRONT AXIS: these Quaternius bodies face -Y in Blender (verified empirically —
|
|
# a +Y test projection landed on the character's back). So "front" = -Y.
|
|
FRONT_Y_SIGN = -1.0
|
|
CHEST_X = (-0.12, 0.12)
|
|
CHEST_Z = (1.16, 1.44)
|
|
CHEST_FRONT_Y = 0.015 # face centre must be on the front side by at least this
|
|
CHEST_NORMAL_Y = 0.20 # face normal must point forward by at least this much
|
|
|
|
# Region-mask classification (body-local, Z up).
|
|
COLLAR_Z_MIN = 1.49 # faces above this AND near centre -> collar band (R)
|
|
COLLAR_X_ABS = 0.11 # collar band stays near the neck, not the shoulders
|
|
SLEEVE_X_ABS = 0.20 # faces with |center X| beyond this -> sleeve cap (B)
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-body threshold derivation (T-1089 per-body shell mode)
|
|
#
|
|
# The absolute constants above were hand-calibrated on average_m. The ratios
|
|
# below re-express every one of them against average_m's bone landmarks
|
|
# (shoulder = upperarm head |x| 0.1919, neck_01 head z 1.5205 / length 0.0793,
|
|
# spine_01 head z 1.072) so any body derives the SAME proportional cut/mask
|
|
# parameters from its own armature. On average_m the derivation reproduces
|
|
# the legacy constants exactly; the 11 bodies share one 65-bone rig, so the
|
|
# landmarks exist everywhere.
|
|
# --------------------------------------------------------------------------
|
|
_REF_SHOULDER_X = 0.1919
|
|
_REF_NECK_Z = 1.5205
|
|
_REF_NECK_LEN = 0.0793
|
|
_REF_SPINE_LO_Z = 1.072
|
|
|
|
SLEEVE_X_FRAC = SLEEVE_X_ABS / _REF_SHOULDER_X # of shoulder |x|
|
|
COLLAR_X_FRAC = COLLAR_X_ABS / _REF_SHOULDER_X # of shoulder |x|
|
|
COLLAR_DROP_FRAC = (_REF_NECK_Z - COLLAR_Z_MIN) / _REF_NECK_LEN # below neck head
|
|
CHEST_X_FRAC = CHEST_X[1] / _REF_SHOULDER_X # of shoulder |x|
|
|
_REF_SPINE_SPAN = _REF_NECK_Z - _REF_SPINE_LO_Z
|
|
CHEST_Z_LO_FRAC = (CHEST_Z[0] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN
|
|
CHEST_Z_HI_FRAC = (CHEST_Z[1] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN
|
|
|
|
|
|
def log(msg):
|
|
print(f"[offset-shell] {msg}")
|
|
|
|
|
|
def derive_thresholds(armature):
|
|
"""Derive cut/mask thresholds from this body's bone landmarks.
|
|
|
|
Returns a dict {sleeve_x_abs, collar_z_min, collar_x_abs, chest_x, chest_z}.
|
|
Falls back to the legacy average_m constants when landmarks are missing.
|
|
"""
|
|
bones = armature.data.bones
|
|
ua_l = bones.get("upperarm_l")
|
|
ua_r = bones.get("upperarm_r")
|
|
neck = bones.get("neck_01")
|
|
spine01 = bones.get("spine_01")
|
|
if not all([ua_l, ua_r, neck, spine01]):
|
|
log("WARNING: landmark bones missing — using legacy average_m thresholds")
|
|
return {
|
|
"sleeve_x_abs": SLEEVE_X_ABS,
|
|
"collar_z_min": COLLAR_Z_MIN,
|
|
"collar_x_abs": COLLAR_X_ABS,
|
|
"chest_x": CHEST_X,
|
|
"chest_z": CHEST_Z,
|
|
}
|
|
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
|
neck_z = neck.head_local.z
|
|
neck_len = neck.tail_local.z - neck.head_local.z
|
|
spine_lo = spine01.head_local.z
|
|
spine_span = neck_z - spine_lo
|
|
thr = {
|
|
"sleeve_x_abs": shoulder_x * SLEEVE_X_FRAC,
|
|
"collar_z_min": neck_z - COLLAR_DROP_FRAC * neck_len,
|
|
"collar_x_abs": shoulder_x * COLLAR_X_FRAC,
|
|
"chest_x": (-shoulder_x * CHEST_X_FRAC, shoulder_x * CHEST_X_FRAC),
|
|
"chest_z": (spine_lo + CHEST_Z_LO_FRAC * spine_span,
|
|
spine_lo + CHEST_Z_HI_FRAC * spine_span),
|
|
}
|
|
log(f"thresholds: sleeve |x|>={thr['sleeve_x_abs']:.3f} "
|
|
f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_x_abs']:.3f} "
|
|
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
|
|
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
|
|
return thr
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Scene helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
def clear_scene():
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
bpy.ops.object.delete()
|
|
bpy.ops.outliner.orphans_purge(do_recursive=True)
|
|
|
|
|
|
def import_glb(path):
|
|
before = set(bpy.context.scene.objects)
|
|
bpy.ops.import_scene.gltf(filepath=path)
|
|
return [o for o in bpy.context.scene.objects if o not in before]
|
|
|
|
|
|
def is_body_mesh(obj):
|
|
"""A real skinned body segment mesh — not Icosphere debris."""
|
|
if obj.type != 'MESH':
|
|
return False
|
|
if obj.name.startswith("Icosphere"):
|
|
return False
|
|
if len(obj.vertex_groups) == 0:
|
|
return False
|
|
if len(obj.data.vertices) < 50:
|
|
return False
|
|
return True
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Build the joined shell base
|
|
# --------------------------------------------------------------------------
|
|
|
|
def build_covered_mesh(body_dir):
|
|
"""Import covered segments, keep skinned meshes, join to one mesh + armature."""
|
|
body_meshes = []
|
|
armature = None
|
|
|
|
for seg in COVERED_SEGMENTS:
|
|
path = os.path.join(body_dir, f"{seg}.glb")
|
|
if not os.path.isfile(path):
|
|
log(f"WARNING: missing segment {path} — skipping")
|
|
continue
|
|
objs = import_glb(path)
|
|
for o in objs:
|
|
if o.type == 'ARMATURE' and armature is None:
|
|
armature = o
|
|
elif o.type == 'ARMATURE':
|
|
# drop extra armature copies (identical rest pose)
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
elif is_body_mesh(o):
|
|
body_meshes.append(o)
|
|
else:
|
|
# Icosphere / debris
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
|
|
if not body_meshes:
|
|
raise RuntimeError("no skinned body meshes imported for covered segments")
|
|
if armature is None:
|
|
raise RuntimeError("no armature found in covered segments")
|
|
|
|
# Join meshes (vertex groups merge by name across segments).
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for m in body_meshes:
|
|
m.select_set(True)
|
|
bpy.context.view_layer.objects.active = body_meshes[0]
|
|
bpy.ops.object.join()
|
|
shell = bpy.context.active_object
|
|
shell.name = "garment_shell"
|
|
|
|
# Re-point the armature modifier at the surviving armature; re-parent.
|
|
for mod in list(shell.modifiers):
|
|
if mod.type == 'ARMATURE':
|
|
mod.object = armature
|
|
shell.parent = armature
|
|
shell.matrix_parent_inverse = armature.matrix_world.inverted()
|
|
|
|
log(f"joined shell: {len(shell.data.vertices)} verts, "
|
|
f"{len(shell.data.polygons)} faces, {len(shell.vertex_groups)} vgroups")
|
|
return shell, armature
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Bone-plane sleeve cut
|
|
# --------------------------------------------------------------------------
|
|
|
|
def sleeve_cut(shell, armature):
|
|
"""Delete sleeve-tip verts beyond the short-sleeve plane on each upper arm.
|
|
|
|
The upper arm runs along +/-X (shoulder head -> elbow tail). We keep the
|
|
fraction SLEEVE_FRAC of that length from the shoulder and delete the rest.
|
|
Torso verts stay (|X| < shoulder head), so a single coordinate threshold is
|
|
safe and needs no per-vertex weight test.
|
|
"""
|
|
bones = armature.data.bones
|
|
cut_planes = [] # (axis_sign, threshold_x)
|
|
for bone_name, sign in [("upperarm_l", +1), ("upperarm_r", -1)]:
|
|
b = bones.get(bone_name)
|
|
if b is None:
|
|
log(f"WARNING: bone {bone_name} missing — sleeve not cut on that side")
|
|
continue
|
|
head_x = b.head_local.x
|
|
tail_x = b.tail_local.x
|
|
thr = head_x + SLEEVE_FRAC * (tail_x - head_x)
|
|
cut_planes.append((sign, thr))
|
|
log(f"sleeve cut {bone_name}: keep |x| up to {thr:.3f} "
|
|
f"(shoulder {head_x:.3f} -> elbow {tail_x:.3f})")
|
|
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
bm.verts.ensure_lookup_table()
|
|
to_delete = []
|
|
for v in bm.verts:
|
|
for sign, thr in cut_planes:
|
|
if sign > 0 and v.co.x > thr:
|
|
to_delete.append(v)
|
|
break
|
|
if sign < 0 and v.co.x < thr:
|
|
to_delete.append(v)
|
|
break
|
|
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"sleeve cut removed {len(to_delete)} verts; "
|
|
f"{len(shell.data.vertices)} remain")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Outward offset + solidify
|
|
# --------------------------------------------------------------------------
|
|
|
|
def offset_outward(shell, offset):
|
|
"""Push every vertex outward along its (smoothed) normal by `offset` m."""
|
|
me = shell.data
|
|
me.calc_normals_split() if hasattr(me, "calc_normals_split") else None
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.normal_update()
|
|
for v in bm.verts:
|
|
v.co += v.normal * offset
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
log(f"offset surface outward by {offset*1000:.0f} mm along normals")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Convex toe box (T-1089 footwear fix — shared by sneakers/shoes/boots)
|
|
#
|
|
# Closed shoes have a smooth rigid TOE BOX: a convex rounded cap the toes sit
|
|
# INSIDE, not a shell that wraps each toe. The earlier per-script approach
|
|
# (Laplacian smooth the toes, then push verts back out to the ORIGINAL skin
|
|
# surface) re-imprinted the individual toes — the skin-conforming clamp
|
|
# followed each toe bump, so bumps/pokes survived. This routine instead
|
|
# forces every toe cross-section onto one analytic half-ellipse dome that
|
|
# CIRCUMSCRIBES the toes (guaranteed outside the skin, so no poke, and no
|
|
# per-toe detail survives), extends the nose forward past the longest toe,
|
|
# and rebinds the whole box UNIFORMLY to the ball bone so it flexes rigidly
|
|
# at the ball joint with no per-vertex toe-weight ripple under animation.
|
|
#
|
|
# Applied PRE-offset: the mold encloses the skin toes by construction, then
|
|
# offset_outward adds the standoff uniformly over a smooth surface. No skin
|
|
# clamp is needed (or wanted) in the toe zone afterward.
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _smoothstep(t):
|
|
t = min(max(t, 0.0), 1.0)
|
|
return t * t * (3.0 - 2.0 * t)
|
|
|
|
|
|
def foot_ball_u(armature):
|
|
"""Forward coord (u = y*FRONT_Y_SIGN) of the ball joint (toe-box hinge).
|
|
|
|
Bodies face -Y so toes point -Y; u increases toward the toes. ball_l/ball_r
|
|
share the same forward head coord (feet are x-mirror symmetric)."""
|
|
ball = armature.data.bones.get("ball_l")
|
|
if ball is None:
|
|
return None
|
|
return ball.head_local.y * FRONT_Y_SIGN
|
|
|
|
|
|
def _interp(x, xp, fp):
|
|
"""Minimal linear interp with flat ends (np.interp semantics, no import)."""
|
|
if x <= xp[0]:
|
|
return fp[0]
|
|
if x >= xp[-1]:
|
|
return fp[-1]
|
|
for i in range(1, len(xp)):
|
|
if x < xp[i]:
|
|
t = (x - xp[i - 1]) / max(xp[i] - xp[i - 1], 1e-9)
|
|
return fp[i - 1] + t * (fp[i] - fp[i - 1])
|
|
return fp[-1]
|
|
|
|
|
|
def convex_toe_box(shell, armature, *, extension, width_margin, height_clear,
|
|
nbins=10, feather_m=0.020, bottom_band_m=0.0015,
|
|
nose_frac=0.40, smooth_iters=7, smooth_factor=0.6,
|
|
uniform_ball_weights=True):
|
|
"""Reshape the forefoot into a smooth convex toe box (per foot side).
|
|
|
|
Each cross-section forward of the ball joint is forced onto ONE smooth
|
|
ellipse that circumscribes that slice's toe verts — every individual-toe
|
|
bump/crevice is erased and the shell sits OUTSIDE the skin (the ellipse is
|
|
the slice's own enclosing ellipse + a margin, so projecting only pushes
|
|
verts outward). Sizes are measured per u-slice (never a single collapsing
|
|
quadric, which over-inflates), so the box follows the foot's natural taper
|
|
while reading as one rigid cap. The frontmost `nose_frac` of the toe length
|
|
is pushed forward up to `extension` past the longest toe. The whole cap is
|
|
rebound uniformly to the ball bone so it flexes rigidly at the ball joint
|
|
with no per-vertex toe-weight ripple.
|
|
|
|
extension forward nose extension past the longest toe (m).
|
|
width_margin half-width padding added around each slice (m).
|
|
height_clear vertical headroom added above the toes (m) — flex room.
|
|
nbins number of u-slices sized independently along the toe length.
|
|
feather_m blend band behind the ball over which effect + rebind ramp.
|
|
bottom_band_m underside band left for the sole routine (dome does top+sides).
|
|
nose_frac fraction of the toe length (from the tip back) that is pushed
|
|
forward to form the extended rounded nose.
|
|
"""
|
|
ball_u = foot_ball_u(armature)
|
|
if ball_u is None:
|
|
log("WARNING: ball_l missing — convex toe box skipped")
|
|
return
|
|
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
bm.normal_update() # pre-reshape normals gate the underside (sole) verts
|
|
dl = bm.verts.layers.deform.verify()
|
|
gi = {g.name: g.index for g in shell.vertex_groups}
|
|
ball_gi = {1: gi.get("ball_l"), -1: gi.get("ball_r")}
|
|
|
|
verts = list(bm.verts)
|
|
feather_u0 = ball_u - feather_m
|
|
total_reshaped = 0
|
|
for side in (1, -1):
|
|
sverts = [v for v in verts if (v.co.x * side) > 0.0]
|
|
toe = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > ball_u]
|
|
if len(toe) < 6:
|
|
continue
|
|
cx = sum(v.co.x for v in toe) / len(toe)
|
|
base_z = min(v.co.z for v in sverts) # per-side sole level
|
|
u_tip = max(v.co.y * FRONT_Y_SIGN for v in toe)
|
|
span = max(u_tip - ball_u, 1e-6)
|
|
|
|
# --- per-slice circumscribing ellipse (top + side verts only) --------
|
|
centers = [ball_u + span * (i + 0.5) / nbins for i in range(nbins)]
|
|
Barr = [width_margin] * nbins
|
|
Harr = [height_clear] * nbins
|
|
bin_verts = [[] for _ in range(nbins)]
|
|
for v in toe:
|
|
if (v.co.z - base_z) <= bottom_band_m:
|
|
continue # underside -> sole routine
|
|
i = int((v.co.y * FRONT_Y_SIGN - ball_u) / span * nbins)
|
|
i = min(max(i, 0), nbins - 1)
|
|
bin_verts[i].append(v)
|
|
for i in range(nbins):
|
|
bv = bin_verts[i]
|
|
if not bv:
|
|
continue
|
|
b0 = max(abs(v.co.x - cx) for v in bv) + width_margin
|
|
h0 = max(v.co.z - base_z for v in bv) + height_clear
|
|
# circumscribe: scale the (b0,h0) ellipse until it holds every vert
|
|
kmax = 1.0
|
|
for v in bv:
|
|
rr = (((v.co.x - cx) / b0) ** 2
|
|
+ ((v.co.z - base_z) / h0) ** 2) ** 0.5
|
|
kmax = max(kmax, rr)
|
|
Barr[i] = b0 * kmax
|
|
Harr[i] = h0 * kmax
|
|
# fill empty bins by carrying the last known size forward/back
|
|
for i in range(1, nbins):
|
|
if bin_verts[i] == [] or Barr[i] == width_margin:
|
|
Barr[i], Harr[i] = Barr[i - 1], Harr[i - 1]
|
|
# one along-length smoothing pass (keeps the cap from stepping)
|
|
Bs = list(Barr)
|
|
Hs = list(Harr)
|
|
for i in range(1, nbins - 1):
|
|
Bs[i] = 0.25 * Barr[i - 1] + 0.5 * Barr[i] + 0.25 * Barr[i + 1]
|
|
Hs[i] = 0.25 * Harr[i - 1] + 0.5 * Harr[i] + 0.25 * Harr[i + 1]
|
|
|
|
nose_start = u_tip - nose_frac * span
|
|
work = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > feather_u0]
|
|
|
|
# Capture the underside gate from the SKIN (pre-smooth) normals so it
|
|
# matches how the per-style sole routine classifies its verts (roughly
|
|
# normal.z < -0.5). Verts the sole owns are excluded from the cap, so
|
|
# the cap never fights the sole flatten (which caused underside tears).
|
|
gate = {}
|
|
for v in work:
|
|
gate[v.index] = _smoothstep((v.normal.z + 0.5) / 0.25) # -0.5->0
|
|
|
|
# --- fill the between-toe notches (Laplacian) BEFORE projecting -------
|
|
# The individual-toe crevices are deep valleys; in-place ellipse
|
|
# projection alone leaves their walls. Smoothing melts the valleys into
|
|
# one volume (like the old pipeline) — but the ellipse SIZES above were
|
|
# measured from the ORIGINAL toe, so the projection below pushes the
|
|
# smoothed (shrunk) surface back OUT onto a cap that still encloses the
|
|
# real skin. The uniform ball rebind fixes the flex ripple that made
|
|
# the old pipeline keep its smoothing timid.
|
|
if smooth_iters > 0:
|
|
toe_all = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > ball_u]
|
|
for _ in range(smooth_iters):
|
|
bmesh.ops.smooth_vert(bm, verts=toe_all, factor=smooth_factor,
|
|
use_axis_x=True, use_axis_y=True,
|
|
use_axis_z=True)
|
|
|
|
n_side = 0
|
|
for v in work:
|
|
u = v.co.y * FRONT_Y_SIGN
|
|
f = _smoothstep((u - feather_u0) / max(ball_u - feather_u0, 1e-6))
|
|
# ellipse size at this u (from the pre-stretch position)
|
|
B = _interp(u, centers, Bs)
|
|
H = _interp(u, centers, Hs)
|
|
hpos = v.co.z - base_z
|
|
wn = gate[v.index]
|
|
wh = 1.0 if hpos > bottom_band_m else 0.0
|
|
w = f * wn * wh
|
|
dx = v.co.x - cx
|
|
hh = max(hpos, 0.0)
|
|
rr = ((dx / B) ** 2 + (hh / H) ** 2) ** 0.5
|
|
if w > 1e-6 and rr > 1e-6:
|
|
scale = min(max(1.0 / rr, 0.5), 2.5)
|
|
tx = cx + dx * scale
|
|
tz = base_z + hh * scale
|
|
v.co.x += (tx - v.co.x) * w
|
|
v.co.z += (tz - v.co.z) * w
|
|
n_side += 1
|
|
# forward nose push (feathered from nose_start to the tip)
|
|
if u > nose_start:
|
|
t = _smoothstep((u - nose_start) / max(u_tip - nose_start, 1e-6))
|
|
v.co.y += -extension * t * FRONT_Y_SIGN * f
|
|
|
|
# Uniform ball rebinding (feathered by the length feather f).
|
|
bi = ball_gi[side]
|
|
if uniform_ball_weights and bi is not None:
|
|
for v in work:
|
|
u = v.co.y * FRONT_Y_SIGN
|
|
f = _smoothstep((u - feather_u0) / max(ball_u - feather_u0, 1e-6))
|
|
if f <= 1e-6:
|
|
continue
|
|
dv = v[dl]
|
|
for gidx in list(dv.keys()):
|
|
dv[gidx] = dv[gidx] * (1.0 - f)
|
|
cur = dv[bi] if bi in dv else 0.0
|
|
dv[bi] = cur + f
|
|
tot = sum(dv[g] for g in dv.keys())
|
|
if tot > 1e-8:
|
|
for gidx in list(dv.keys()):
|
|
dv[gidx] = dv[gidx] / tot
|
|
total_reshaped += n_side
|
|
log(f"toe box side {'L' if side > 0 else 'R'}: {len(toe)} toe verts, "
|
|
f"B={min(Bs) * 1000:.0f}-{max(Bs) * 1000:.0f}mm "
|
|
f"H={min(Hs) * 1000:.0f}-{max(Hs) * 1000:.0f}mm "
|
|
f"cap +{extension * 1000:.0f}mm, reshaped {n_side}")
|
|
|
|
bm.normal_update()
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
log(f"convex toe box: reshaped {total_reshaped} verts "
|
|
f"(headroom {height_clear * 1000:.0f}mm, nose +{extension * 1000:.0f}mm)")
|
|
|
|
|
|
def solidify(shell, thickness):
|
|
"""Solidify with use_rim to give cloth thickness and cap the cut rims."""
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
shell.select_set(True)
|
|
bpy.context.view_layer.objects.active = shell
|
|
# consistent outward normals first
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
bpy.ops.mesh.normals_make_consistent(inside=False)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
sol = shell.modifiers.new(name="Solidify", type='SOLIDIFY')
|
|
sol.thickness = thickness
|
|
sol.offset = 1.0 # grow outward only
|
|
sol.use_rim = True # cap open boundaries (sleeve/neck/hem)
|
|
sol.use_rim_only = False
|
|
bpy.ops.object.modifier_apply(modifier=sol.name)
|
|
log(f"solidified: {thickness*1000:.0f} mm, use_rim; "
|
|
f"{len(shell.data.vertices)} verts")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Material (flat fabric albedo)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def make_base_albedo_image(seed=1089):
|
|
img = bpy.data.images.new("garment_base_albedo", ALBEDO_SIZE, ALBEDO_SIZE, alpha=False)
|
|
rng = np.random.default_rng(seed)
|
|
base = np.array(FABRIC_RGB, dtype=np.float32)
|
|
noise = (rng.random((ALBEDO_SIZE * ALBEDO_SIZE, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE
|
|
rgb = np.clip(base[None, :] + noise, 0.0, 1.0)
|
|
rgba = np.concatenate([rgb, np.ones((ALBEDO_SIZE * ALBEDO_SIZE, 1), dtype=np.float32)], axis=1)
|
|
img.pixels.foreach_set(rgba.reshape(-1))
|
|
img.update()
|
|
return img
|
|
|
|
|
|
def assign_fabric_material(shell, albedo_img):
|
|
shell.data.materials.clear()
|
|
mat = bpy.data.materials.new("garment_fabric")
|
|
mat.use_nodes = True
|
|
nt = mat.node_tree
|
|
bsdf = nt.nodes.get("Principled BSDF")
|
|
tex = nt.nodes.new("ShaderNodeTexImage")
|
|
tex.image = albedo_img
|
|
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
|
if "Roughness" in bsdf.inputs:
|
|
bsdf.inputs["Roughness"].default_value = 0.9
|
|
shell.data.materials.append(mat)
|
|
log("assigned flat fabric material (skin textures dropped)")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Region mask bake (UV0-aligned, per-face rasterization)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _classify_region(center, thr):
|
|
"""Return an RGBA region colour for a face centre (body-local coords)."""
|
|
x, z = center.x, center.z
|
|
if abs(x) >= thr["sleeve_x_abs"]:
|
|
return (0.0, 0.0, 1.0, 0.0) # sleeve caps -> B (tint[2])
|
|
if z >= thr["collar_z_min"] and abs(x) < thr["collar_x_abs"]:
|
|
return (1.0, 0.0, 0.0, 0.0) # neck collar band -> R (tint[0])
|
|
return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1])
|
|
|
|
|
|
def _tris_from_face(face, uv_layer):
|
|
"""Fan-triangulate a bmesh face into (uv, uv, uv) tuples in [0,1] space."""
|
|
loops = face.loops[:]
|
|
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
|
tris = []
|
|
for i in range(1, len(uvs) - 1):
|
|
tris.append((uvs[0], uvs[i], uvs[i + 1]))
|
|
return tris
|
|
|
|
|
|
def bake_region_mask(shell, out_path, thr):
|
|
"""Rasterize each face's UV0 triangle with its region colour into MASK_SIZE^2.
|
|
|
|
Background initialised to main-body green so bilinear bleed at island edges
|
|
never lands on an untinted (all-zero) texel.
|
|
"""
|
|
W = H = MASK_SIZE
|
|
buf = np.zeros((H, W, 4), dtype=np.float32)
|
|
buf[:, :, 1] = 1.0 # green background = main body
|
|
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.faces.ensure_lookup_table()
|
|
uv_layer = bm.loops.layers.uv.active
|
|
if uv_layer is None:
|
|
raise RuntimeError("no active UV layer for region mask bake")
|
|
|
|
region_counts = {"collar": 0, "body": 0, "sleeve": 0}
|
|
for face in bm.faces:
|
|
color = _classify_region(face.calc_center_median(), thr)
|
|
if color[0] > 0.5:
|
|
region_counts["collar"] += 1
|
|
elif color[2] > 0.5:
|
|
region_counts["sleeve"] += 1
|
|
else:
|
|
region_counts["body"] += 1
|
|
for a, b, c in _tris_from_face(face, uv_layer):
|
|
_raster_tri(buf, a, b, c, color, W, H)
|
|
bm.free()
|
|
total = max(sum(region_counts.values()), 1)
|
|
log("region faces: " + " ".join(
|
|
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in region_counts.items()))
|
|
|
|
# Blender image is bottom-up; buf row 0 is V=0 (bottom) already since we
|
|
# rasterise with row = v*(H-1). Save via Blender to match the texture pipe.
|
|
img = bpy.data.images.new("garment_region_mask", W, H, alpha=True)
|
|
img.pixels.foreach_set(buf.reshape(-1))
|
|
img.update()
|
|
img.filepath_raw = out_path
|
|
img.file_format = 'PNG'
|
|
img.save()
|
|
log(f"baked region mask -> {out_path}")
|
|
|
|
|
|
def _raster_tri(buf, a, b, c, color, W, H):
|
|
"""Barycentric fill of a UV triangle into buf (V=0 at row 0 = bottom)."""
|
|
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
|
bx, by = b.x * (W - 1), b.y * (H - 1)
|
|
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
|
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
|
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
|
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
|
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
|
if minx > maxx or miny > maxy:
|
|
return
|
|
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
|
if abs(denom) < 1e-9:
|
|
return
|
|
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
|
px = xs + 0.5
|
|
py = ys + 0.5
|
|
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
|
|
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
|
|
w2 = 1.0 - w0 - w1
|
|
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
|
if not inside.any():
|
|
return
|
|
region = buf[miny:maxy + 1, minx:maxx + 1, :]
|
|
col = np.array(color, dtype=np.float32)
|
|
region[inside] = col
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Logo UV2 chest channel
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_logo_uv(shell, thr):
|
|
"""Create a 2nd UV layer projecting front chest faces into [0,1]; park the
|
|
rest outside the box (shader guards uv2 in [0,1])."""
|
|
me = shell.data
|
|
# Keep exactly two UV layers: primary (albedo/mask) + logo. Remove extras.
|
|
while len(me.uv_layers) > 1:
|
|
me.uv_layers.remove(me.uv_layers[-1])
|
|
logo_uv = me.uv_layers.new(name="logo_uv")
|
|
me.uv_layers.active = me.uv_layers[0] # keep albedo layer active for mask bake safety
|
|
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.faces.ensure_lookup_table()
|
|
bm.normal_update()
|
|
uvl = bm.loops.layers.uv.get("logo_uv")
|
|
|
|
x0, x1 = thr["chest_x"]
|
|
z0, z1 = thr["chest_z"]
|
|
placed = 0
|
|
for face in bm.faces:
|
|
center = face.calc_center_median()
|
|
on_chest = (
|
|
center.y * FRONT_Y_SIGN > CHEST_FRONT_Y
|
|
and x0 <= center.x <= x1
|
|
and z0 <= center.z <= z1
|
|
and face.normal.y * FRONT_Y_SIGN > CHEST_NORMAL_Y
|
|
)
|
|
for loop in face.loops:
|
|
if on_chest:
|
|
co = loop.vert.co
|
|
# Empirically calibrated for the -Y front so the wordmark reads
|
|
# upright and left-to-right from the camera (see report: a plain
|
|
# projection came out 180deg-rotated on this rig).
|
|
u = (co.x - x0) / (x1 - x0)
|
|
v = (co.z - z0) / (z1 - z0)
|
|
loop[uvl].uv = (min(max(u, 0.0), 1.0), min(max(v, 0.0), 1.0))
|
|
else:
|
|
loop[uvl].uv = (2.0, 2.0) # parked outside box
|
|
if on_chest:
|
|
placed += 1
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
log(f"logo UV2 authored on {placed} chest faces")
|
|
if placed == 0:
|
|
log("WARNING: no chest faces matched — check CHEST_* box / front axis")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Export
|
|
# --------------------------------------------------------------------------
|
|
|
|
def export_reference(shell, armature, out_path):
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
shell.select_set(True)
|
|
armature.select_set(True)
|
|
bpy.context.view_layer.objects.active = armature
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=out_path,
|
|
export_format='GLB',
|
|
use_selection=True,
|
|
export_apply=False, # keep Armature modifier for skinning
|
|
export_animations=False,
|
|
export_skins=True,
|
|
export_yup=True,
|
|
export_texcoords=True,
|
|
export_normals=True,
|
|
export_materials='EXPORT',
|
|
export_image_format='AUTO',
|
|
)
|
|
size_kb = os.path.getsize(out_path) // 1024
|
|
log(f"exported reference -> {out_path} ({size_kb} KB)")
|
|
|
|
|
|
def save_albedo_sidecar(albedo_img, out_path):
|
|
albedo_img.filepath_raw = out_path
|
|
albedo_img.file_format = 'PNG'
|
|
albedo_img.save()
|
|
log(f"saved base albedo -> {out_path}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Entry
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_shell(body_dir, out_dir, glb_name, mask_name, offset):
|
|
"""Author one offset-shell garment from `body_dir`'s segments.
|
|
|
|
Shared by both modes; thresholds derive from the body's own armature so
|
|
the same proportional cut/mask parameters apply on every body.
|
|
"""
|
|
clear_scene()
|
|
shell, armature = build_covered_mesh(body_dir)
|
|
thr = derive_thresholds(armature)
|
|
sleeve_cut(shell, armature)
|
|
offset_outward(shell, offset)
|
|
solidify(shell, CLOTH_THICKNESS_M)
|
|
|
|
albedo_img = make_base_albedo_image()
|
|
assign_fabric_material(shell, albedo_img)
|
|
|
|
author_logo_uv(shell, thr) # do UV2 before mask bake (mask uses UV0/active)
|
|
bake_region_mask(shell, os.path.join(out_dir, mask_name), thr)
|
|
save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
|
|
|
export_reference(shell, armature, os.path.join(out_dir, glb_name))
|
|
|
|
|
|
def main():
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
if len(argv) < 2:
|
|
print("Usage: -- <bodies_dir>/average_m <out_dir> "
|
|
"[--offset M] [--sleeve-frac F]\n"
|
|
" or: -- <bodies_dir> <out_dir> --per-body "
|
|
"[--bodies a,b,c] [--offset M] [--sleeve-frac F]")
|
|
sys.exit(1)
|
|
in_dir = argv[0]
|
|
out_dir = argv[1]
|
|
per_body = "--per-body" in argv
|
|
|
|
global SLEEVE_FRAC
|
|
offset = None
|
|
if "--offset" in argv:
|
|
offset = float(argv[argv.index("--offset") + 1])
|
|
if "--sleeve-frac" in argv:
|
|
SLEEVE_FRAC = float(argv[argv.index("--sleeve-frac") + 1])
|
|
bodies = BODY_TYPES
|
|
if "--bodies" in argv:
|
|
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
|
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
if not per_body:
|
|
# Single-reference mode: <in_dir> is one body's segment dir.
|
|
offset = OFFSET_M if offset is None else offset
|
|
body = os.path.basename(os.path.normpath(in_dir))
|
|
author_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png", offset)
|
|
log("DONE")
|
|
return
|
|
|
|
# Per-body mode: <in_dir> is the bodies root; loop each body's own segments.
|
|
offset = PER_BODY_OFFSET_M if offset is None else offset
|
|
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
|
|
results = []
|
|
for body in bodies:
|
|
body_dir = os.path.join(in_dir, body)
|
|
log(f"=== {body} ===")
|
|
if not os.path.isdir(body_dir):
|
|
results.append((body, "skipped: body dir missing"))
|
|
continue
|
|
try:
|
|
author_shell(body_dir, out_dir, f"{body}.glb", f"{body}_mask.png",
|
|
offset)
|
|
results.append((body, "ok"))
|
|
except Exception as exc:
|
|
log(f"ERROR {body}: {exc}")
|
|
results.append((body, f"error: {exc}"))
|
|
|
|
# Runtime fallback + SD-fit reference compatibility: reference_mask.png
|
|
# mirrors the reference body's mask.
|
|
ref_mask = os.path.join(out_dir, f"{REFERENCE_BODY}_mask.png")
|
|
if os.path.isfile(ref_mask):
|
|
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
|
log(f"copied {REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
|
|
|
|
log("=" * 50)
|
|
for body, status in results:
|
|
log(f" {body:12s} {status}")
|
|
ok = sum(1 for _, s in results if s == "ok")
|
|
log(f"OK={ok}/{len(results)}")
|
|
if ok != len(results):
|
|
sys.exit(1)
|
|
log("DONE")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|