refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
35 payloads move to tooling/scripts/blender/ and stay outside package scope. They run under Blender's bundled Python, which cannot see the repo venv, so they physically cannot import tooling.core — holding them to the D-263 contract would either fail the gate forever or force the contract to be weakened for everyone, and the second is how a gate stops meaning anything. Count verified by import rather than filename: 33 import bpy/bmesh directly, and the two that do not are still payloads per their own usage lines. garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290. The bash wrapper is retired rather than kept. Keeping it would have put the install-resolution logic in two places, which is the duplication T-1286 had just finished collapsing three copies of. domains/blender/service.py owns the decisions — resolve_blender (native beats flatpak, ordering preserved), resolve_payload, absolutise — and only run_payload performs. test_blender.py pins all of them without launching Blender, which matters here more than usual: the thing being launched is a 200 MB GUI application that writes GLBs. `reach blender run` takes a registered payload name OR a path to any script, because the wrapper served both — the spikes and the glb-gen skill hand it one-off scripts of their own. An unknown name enumerates all 35 and exits 2. The exclusion now defends itself. check_carve_out_stays_carved fails if `scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty exclusion proves nothing), or if an __init__.py appears there (which would make the payloads importable — the coupling the carve-out exists to prevent). All three arms mutation-proved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
"""
|
||||
blender_author_boots.py (T-1089 wave 2, boots_modern + ankle-boot family)
|
||||
|
||||
Authors ANKLE BOOTS as per-body offset shells: both feet PLUS the lower calf
|
||||
(seg_leg_lower cut to a boot shaft just above the ankle), with a chunky sole
|
||||
extension and painted eyelets/laces up the shaft. Reuses
|
||||
blender_author_offset_shell.py (via blender_author_denim_pants.py, imported as
|
||||
a module) for the shared machinery — scene build, join, offset, solidify,
|
||||
albedo material, GLB export — plus the two denim REQUIRED PRACTICES for
|
||||
bottoms/footwear:
|
||||
|
||||
* boundary WELD of coincident segment-seam rings (denim.weld_boundaries):
|
||||
the ankle joins (foot<->leg_lower) duplicate a coincident vert band per
|
||||
segment; offsetting un-welded rings along diverging normals opens cracks.
|
||||
* open-rim FLATTENING: the shaft cut leaves a jagged "teeth" ring on each
|
||||
calf; both rims are pulled down onto one clean shared plane (the deeper
|
||||
valley of the two, so the boots match) before offsetting.
|
||||
|
||||
What this companion adds, as reusable parameters (not hacks):
|
||||
|
||||
* --shaft-frac: boot shaft height as a fraction of the ankle->knee calf
|
||||
span (0.35 cut => rim lands on the lower calf after rim flattening;
|
||||
larger values give combat/riding variants).
|
||||
* TOE-BOX MERGE: the skin mesh has individual toes; a boot must not. The
|
||||
foot region is Laplacian-smoothed pre-offset (mild on the whole foot,
|
||||
aggressive forward of the ball joint) so the toes fuse into one rounded
|
||||
leather toe box and anatomical detail (ankle knobs, heel tendon) reads
|
||||
as boot, not foot. Weights/UVs ride along untouched.
|
||||
* SKIN CONTAINMENT CLAMP: smoothing can pull the shell inside the skin it
|
||||
no longer follows (melted toe box vs real toes). A BVH of the ORIGINAL
|
||||
welded skin is kept, and after the offset every foot-region vert whose
|
||||
signed distance to the skin is below a minimum clearance is pushed back
|
||||
out along the skin normal — standoff guaranteed by construction, which
|
||||
is what the chromakey QA gates on (the runtime does not hide segments).
|
||||
* FOOT WEIGHT RE-BIND: smoothing + clamping RELOCATE shell verts but leave
|
||||
them carrying their ORIGIN vertex's bone weights, so material that ends
|
||||
up hovering over toe N flexes with the bone of toe M — under ball-joint
|
||||
flexion (Walk push-off, crouch) the shell diverges from the skin beneath
|
||||
it and the toes poke through (QA/preview evidence, wave 2 run 1/2).
|
||||
After shaping, every foot-region vert re-copies its vertex-group weights
|
||||
from the nearest vert of the original welded skin, so the boot flexes
|
||||
exactly with the anatomy each patch of leather actually covers.
|
||||
* SOLE geometry as an offset-shell param extension (--sole-drop): the
|
||||
under-foot surface created by the outward offset is flattened onto a slab
|
||||
plane sole_drop below the skin sole (solidify adds its thickness back,
|
||||
so the shipped slab bottom sits exactly sole_drop under the skin), and a
|
||||
feathered radial LIP (~5 mm) bulges the sole band outward for the chunky
|
||||
work-boot silhouette.
|
||||
* --shaft-flare: extra feathered radial stand-off toward the shaft rim.
|
||||
Serves the same crouch-fold purpose as the denim waist flare AND buys
|
||||
clearance so full-length pant hems (jeans/formal, 12 mm standoff) tuck
|
||||
INSIDE the boot shaft instead of z-fighting with it.
|
||||
* TEXEL-level feature painting (denim technique, boot field): one analytic
|
||||
field evaluation drives BOTH the painted albedo and the region mask so
|
||||
they always agree — lace bars + eyelet dots up the front of the shaft,
|
||||
welt stitching above the sole, padded collar shading at the rim.
|
||||
Regions (spec): sole -> R, upper + shaft -> G, laces + eyelets -> B.
|
||||
* a parked logo_uv TEXCOORD_1 layer (boots are not logo-capable, but
|
||||
toon_garment.gdshader samples UV2 unconditionally).
|
||||
|
||||
All cut/mask/paint parameters derive PER BODY from that body's own calf bone
|
||||
landmarks and measured skin-sole plane, scaled by the calf-span ratio against
|
||||
average_m (clamped so the child keeps believable, not clown, proportions) —
|
||||
the same proportional-ratio philosophy as base.derive_thresholds. Per-body
|
||||
mode only (offset shells author per body, Q-060).
|
||||
|
||||
Style pin (T-1089): modern only; identity carried by the texture. The albedo
|
||||
is painted at mid luma so toon_garment.gdshader's luma-preserving recolor
|
||||
stays faithful; the brown/black work-street default lives in the manifest
|
||||
default_tints (sole near-black, upper work brown, laces dark).
|
||||
|
||||
Usage (boots_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_boots.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/boots_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.013] [--shaft-frac 0.30] \
|
||||
[--shaft-flare 0.008] [--sole-drop 0.020] [--base-rgb r,g,b] [--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the denim companion as a library (which itself imports the base
|
||||
# offset-shell module). Boot reuse: base machinery + denim's weld/parked-UV2.
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"denim_pants_lib", os.path.join(_HERE, "blender_author_denim_pants.py"))
|
||||
denim = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(denim)
|
||||
|
||||
base = denim.base
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_foot_l", "seg_foot_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
]
|
||||
|
||||
SHAFT_FRAC = 0.35 # boot shaft = this fraction of the ankle->knee span
|
||||
BOOT_OFFSET_M = 0.013 # standoff along normals (leather sits off the skin)
|
||||
SHAFT_FLARE_M = 0.008 # extra radial stand-off at the shaft rim (feathered)
|
||||
SOLE_DROP_M = 0.020 # chunky sole slab depth below the skin sole (spec)
|
||||
SOLE_LIP_M = 0.008 # radial sole bulge (chunky work-boot lip)
|
||||
SOLE_LIP_TOP_M = 0.020 # lip feather reaches this far above the skin sole
|
||||
TEX_SIZE = 1024 # albedo + mask resolution (painted laces need >512)
|
||||
NOISE_SEED = 3089
|
||||
|
||||
# Convex toe box (shared base.convex_toe_box) — chunky work-boot cap.
|
||||
TOE_EXT_M = 0.010 # nose extension past the longest toe (m)
|
||||
TOE_WMARGIN_M = 0.006 # half-width padding (chunky)
|
||||
TOE_HCLEAR_M = 0.009 # vertical headroom above the toes (flex room)
|
||||
TOE_FEATHER_M = 0.020 # blend band behind the ball
|
||||
|
||||
# Painted-detail metrics (metres on average_m; scaled by the calf-span ratio).
|
||||
LACE_PITCH_M = 0.017 # vertical distance between lace bars
|
||||
LACE_BAR_HW_M = 0.0026 # half-height of a painted lace bar
|
||||
LACE_PANEL_HW_M = 0.015 # half arc-width of the lace panel
|
||||
EYELET_R_M = 0.0032 # eyelet dot radius (at the panel edges)
|
||||
LACE_LO_FRAC = 0.0 # panel starts at the ankle joint (shaft only)
|
||||
COLLAR_H_M = 0.014 # padded collar band at the shaft rim (albedo shade)
|
||||
SOLE_TOP_M = 0.014 # R region reaches this far above the skin sole
|
||||
WELT_OFF_M = 0.0035 # welt stitch line offset above the sole top
|
||||
SEAM_HW_M = 0.0018 # painted stitch line half-width
|
||||
TOECAP_STITCH = True # painted toe-cap stitch line at the ball joint
|
||||
|
||||
# Albedo tones (sRGB floats). Mid-luma leather so the toon_garment luma
|
||||
# recolor keeps its dynamic range; hue defaults come from manifest tints.
|
||||
LEATHER_RGB = (0.58, 0.44, 0.32) # light tan work leather
|
||||
SOLE_RGB = (0.30, 0.29, 0.28) # darker rubber (low luma -> reads black)
|
||||
LACE_RGB = (0.24, 0.21, 0.19) # dark laces
|
||||
EYELET_RGB = (0.85, 0.78, 0.60) # bright metal eyelet glint
|
||||
WELT_RGB = (0.78, 0.62, 0.40) # welt stitching thread
|
||||
PANEL_SHADE = 0.90 # lace-panel background darkening
|
||||
COLLAR_SHADE = 0.86 # padded collar darkening
|
||||
ALBEDO_NOISE = 0.020 # +/- grain jitter
|
||||
PLAIN = False # --plain: skip laces/eyelets/welt paint
|
||||
|
||||
# Reference proportions (average_m) the scale ratio is anchored to.
|
||||
_REF_CALF_SPAN = 0.4559 # calf head z (0.5424) - calf tail z (0.0865)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class BootLandmarks:
|
||||
"""Cut/mask/paint parameters from one body's calf bones + skin-sole plane."""
|
||||
|
||||
def __init__(self, armature, skin_min_z):
|
||||
bones = armature.data.bones
|
||||
calf_l = bones.get("calf_l")
|
||||
calf_r = bones.get("calf_r")
|
||||
if calf_l is None or calf_r is None:
|
||||
raise RuntimeError("calf_l/calf_r missing — not the 65-bone rig?")
|
||||
self.ankle_z = (calf_l.tail_local.z + calf_r.tail_local.z) / 2.0
|
||||
self.knee_z = (calf_l.head_local.z + calf_r.head_local.z) / 2.0
|
||||
self.skin_min_z = skin_min_z
|
||||
self.calf_span = self.knee_z - self.ankle_z
|
||||
# Detail scale: proportional to the calf span, clamped so small bodies
|
||||
# keep believable (not clown, not doll) sole/lace metrics.
|
||||
self.s = min(max(self.calf_span / _REF_CALF_SPAN, 0.55), 1.15)
|
||||
# Per-z leg axis control points (ankle -> knee) for the +x leg;
|
||||
# the right leg mirrors via the x sign. np.interp clamps outside.
|
||||
self.leg_z_pts = np.array([calf_l.tail_local.z, calf_l.head_local.z])
|
||||
self.leg_x_pts = np.array(
|
||||
[abs(calf_l.tail_local.x), abs(calf_l.head_local.x)])
|
||||
self.leg_y_pts = np.array([calf_l.tail_local.y, calf_l.head_local.y])
|
||||
self.shaft_top_z = self.ankle_z + SHAFT_FRAC * self.calf_span
|
||||
self.rim_z = self.shaft_top_z # finalized after rim flattening
|
||||
# Ball joint (toe-box hinge): forward distance on the front axis.
|
||||
ball = bones.get("ball_l")
|
||||
self.ball_front = (ball.head_local.y * FRONT_Y_SIGN
|
||||
if ball is not None else None)
|
||||
|
||||
def finalize(self, rim_z):
|
||||
self.rim_z = rim_z
|
||||
self.sole_top_z = self.skin_min_z + SOLE_TOP_M * self.s
|
||||
self.lace_lo = self.ankle_z - LACE_LO_FRAC * (self.ankle_z - self.skin_min_z)
|
||||
self.lace_hi = self.rim_z - 0.7 * COLLAR_H_M * self.s
|
||||
log(f"landmarks: ankle={self.ankle_z:.3f} knee={self.knee_z:.3f} "
|
||||
f"sole={self.skin_min_z:.3f} rim={self.rim_z:.3f} s={self.s:.2f} "
|
||||
f"sole_top={self.sole_top_z:.3f} "
|
||||
f"laces z=[{self.lace_lo:.3f},{self.lace_hi:.3f}]")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: shaft cut + rim flatten + flare + sole
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def shaft_cut(shell, lm):
|
||||
"""Trim the calves above the boot-shaft plane (bone-derived)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts if v.co.z > lm.shaft_top_z]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"shaft cut at z={lm.shaft_top_z:.3f}: removed {len(doomed)} verts")
|
||||
|
||||
|
||||
def flatten_shaft_rims(shell, lm):
|
||||
"""Pull both shaft-rim teeth rings onto ONE shared clean plane (pre-offset).
|
||||
|
||||
The plane cut follows mesh topology, so each rim is a jagged ring of
|
||||
teeth. Both rings flatten DOWN to the common valley (deepest notch of
|
||||
either ring) — a clean straight rim, identical height on both boots, no
|
||||
invented coverage. Any stray open-boundary verts below the shaft (weld
|
||||
leftovers) are left alone and reported. Returns the rim plane z.
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
low_thresh = lm.ankle_z + 0.25 * (lm.shaft_top_z - lm.ankle_z)
|
||||
rims = [i for i in boundary if bm.verts[i].co.z > low_thresh]
|
||||
stray = len(boundary) - len(rims)
|
||||
if not rims:
|
||||
bm.free()
|
||||
raise RuntimeError("no shaft rim boundary verts found")
|
||||
valley = min(bm.verts[i].co.z for i in rims)
|
||||
teeth = max(bm.verts[i].co.z for i in rims) - valley
|
||||
for i in rims:
|
||||
bm.verts[i].co.z = valley
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened shaft rims: {len(rims)} verts, teeth {teeth:.3f} m "
|
||||
f"-> z={valley:.3f}"
|
||||
+ (f" (WARNING: {stray} stray low boundary verts left)" if stray else ""))
|
||||
return valley
|
||||
|
||||
|
||||
def shaft_flare(shell, lm, rim_z, flare):
|
||||
"""Extra RADIAL stand-off toward the shaft rim, feathered from the ankle
|
||||
(post-offset, pre-solidify). Buys pant-hem clearance + chunky read."""
|
||||
if flare <= 0.0:
|
||||
return
|
||||
span = max(rim_z - lm.ankle_z, 1e-6)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
n = 0
|
||||
for v in bm.verts:
|
||||
if v.co.z > lm.ankle_z:
|
||||
t = min((v.co.z - lm.ankle_z) / span, 1.0)
|
||||
nx, ny = v.normal.x, v.normal.y
|
||||
mag = (nx * nx + ny * ny) ** 0.5
|
||||
if mag > 1e-6:
|
||||
v.co.x += flare * t * nx / mag
|
||||
v.co.y += flare * t * ny / mag
|
||||
n += 1
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"shaft flare: {n} verts, +{flare * 1000:.1f} mm radial at rim")
|
||||
|
||||
|
||||
def sole_shape(shell, lm, offset, sole_drop):
|
||||
"""Chunky sole (post-offset, pre-solidify): feathered radial LIP around
|
||||
the sole band, then the under-foot offset surface flattened onto a slab
|
||||
plane. Solidify(use_rim) then grows its thickness outward (downward
|
||||
there), landing the shipped slab bottom exactly sole_drop below the skin
|
||||
sole."""
|
||||
lip_top = lm.skin_min_z + SOLE_LIP_TOP_M * lm.s
|
||||
lip = SOLE_LIP_M * lm.s
|
||||
slab_z = lm.skin_min_z - (sole_drop * lm.s - base.CLOTH_THICKNESS_M)
|
||||
feather = max(lip_top - lm.skin_min_z, 1e-6)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
n_lip = 0
|
||||
for v in bm.verts:
|
||||
if v.co.z < lip_top:
|
||||
t = min((lip_top - v.co.z) / feather, 1.0)
|
||||
nx, ny = v.normal.x, v.normal.y
|
||||
mag = (nx * nx + ny * ny) ** 0.5
|
||||
if mag > 1e-6:
|
||||
v.co.x += lip * t * nx / mag
|
||||
v.co.y += lip * t * ny / mag
|
||||
n_lip += 1
|
||||
n_flat = 0
|
||||
for v in bm.verts:
|
||||
if v.co.z < lm.skin_min_z:
|
||||
v.co.z = slab_z
|
||||
n_flat += 1
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"sole: lip +{lip * 1000:.1f} mm on {n_lip} verts, "
|
||||
f"{n_flat} under-sole verts flattened -> z={slab_z:.3f} "
|
||||
f"(shipped bottom {sole_drop * lm.s * 1000:.0f} mm below skin sole)")
|
||||
|
||||
|
||||
def clamp_rim_residue(shell, rim_z):
|
||||
"""Post-solidify safety clamp: verts still above the rim plane (solidify
|
||||
displacement on multi-triangle teeth) get squashed onto it."""
|
||||
me = shell.data
|
||||
n = 0
|
||||
for v in me.vertices:
|
||||
if v.co.z > rim_z:
|
||||
v.co.z = rim_z
|
||||
n += 1
|
||||
me.update()
|
||||
if n:
|
||||
log(f"clamped {n} residual rim verts -> {rim_z:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Boot feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _boot_field(px, py, pz, lm):
|
||||
"""Evaluate boot features at texel 3D positions (numpy arrays)."""
|
||||
s = lm.s
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
|
||||
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
r = np.hypot(dx, dy) + 1e-9
|
||||
# Arc distance from the front meridian around the per-z leg axis —
|
||||
# constant metric width at any girth, mirrors correctly on both boots.
|
||||
arc_front = np.arccos(np.clip(dy * FRONT_Y_SIGN / r, -1.0, 1.0)) * r
|
||||
|
||||
in_sole = pz <= lm.sole_top_z
|
||||
in_lace_z = (pz > lm.lace_lo) & (pz < lm.lace_hi) & ~in_sole
|
||||
|
||||
panel = in_lace_z & (arc_front < LACE_PANEL_HW_M * s)
|
||||
pitch = LACE_PITCH_M * s
|
||||
ph = np.mod(pz - lm.lace_lo, pitch)
|
||||
laces = panel & (np.abs(ph - 0.5 * pitch) < LACE_BAR_HW_M * s)
|
||||
eyelets = (
|
||||
in_lace_z
|
||||
& (np.abs(arc_front - LACE_PANEL_HW_M * s) < EYELET_R_M * s)
|
||||
& (np.abs(ph - 0.5 * pitch) < EYELET_R_M * s)
|
||||
)
|
||||
welt = (~in_sole) & (
|
||||
np.abs(pz - (lm.sole_top_z + WELT_OFF_M * s)) < SEAM_HW_M * s)
|
||||
if TOECAP_STITCH and lm.ball_front is not None:
|
||||
welt = welt | (
|
||||
(~in_sole) & (pz < lm.lace_lo)
|
||||
& (np.abs(py * FRONT_Y_SIGN - lm.ball_front) < SEAM_HW_M * s))
|
||||
collar = (~in_sole) & (pz > lm.rim_z - COLLAR_H_M * s)
|
||||
return in_sole, panel, laces, eyelets, welt, collar
|
||||
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
in_sole, panel, laces, eyelets, welt, collar = _boot_field(px, py, pz, lm)
|
||||
|
||||
# --- albedo ------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = LEATHER_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
alb[collar, :3] *= COLLAR_SHADE
|
||||
if not PLAIN:
|
||||
alb[panel, :3] *= PANEL_SHADE
|
||||
for c in range(3):
|
||||
alb[welt, c] = WELT_RGB[c]
|
||||
for c in range(3):
|
||||
alb[in_sole, c] = SOLE_RGB[c] + noise[in_sole]
|
||||
if not PLAIN:
|
||||
for c in range(3):
|
||||
alb[laces, c] = LACE_RGB[c]
|
||||
alb[eyelets, c] = EYELET_RGB[c]
|
||||
|
||||
# --- region mask: sole R / upper+shaft G / laces+eyelets B ---------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_b = (laces | eyelets) & ~in_sole if not PLAIN else np.zeros(n, dtype=bool)
|
||||
is_g = ~(in_sole | is_b)
|
||||
mask[in_sole, 0] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[is_g, 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# UV0 rasterization (denim technique: one pass paints albedo + mask)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the boot field, write albedo + mask together."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = LEATHER_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = upper green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"boot_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"boot_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_boot_shell(body_dir, out_dir, body, offset, sole_drop):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell) # ankle seams: foot <-> leg_lower
|
||||
|
||||
skin_min_z = min(v.co.z for v in shell.data.vertices)
|
||||
lm = BootLandmarks(armature, skin_min_z)
|
||||
|
||||
shaft_cut(shell, lm)
|
||||
rim_z = flatten_shaft_rims(shell, lm)
|
||||
|
||||
# Smooth convex toe box (replaces the toe-merge + skin-conforming
|
||||
# containment clamp + per-toe weight re-bind, which re-imprinted the
|
||||
# individual toes and rippled under flex). The cap encloses the real skin
|
||||
# toes and rebinds uniformly to the ball bone; offset then adds standoff.
|
||||
base.convex_toe_box(
|
||||
shell, armature,
|
||||
extension=TOE_EXT_M * lm.s, width_margin=TOE_WMARGIN_M * lm.s,
|
||||
height_clear=TOE_HCLEAR_M * lm.s, feather_m=TOE_FEATHER_M * lm.s)
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
shaft_flare(shell, lm, rim_z, SHAFT_FLARE_M * lm.s)
|
||||
sole_shape(shell, lm, offset, sole_drop)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
clamp_rim_residue(shell, rim_z)
|
||||
|
||||
lm.finalize(rim_z)
|
||||
denim.author_parked_uv2(shell) # boots are not logo-capable
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global SHAFT_FRAC, SHAFT_FLARE_M, LEATHER_RGB, PLAIN
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--shaft-frac F] [--shaft-flare M] "
|
||||
"[--sole-drop M] [--base-rgb r,g,b] [--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = BOOT_OFFSET_M
|
||||
sole_drop = SOLE_DROP_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--shaft-frac" in argv:
|
||||
SHAFT_FRAC = float(argv[argv.index("--shaft-frac") + 1])
|
||||
if "--shaft-flare" in argv:
|
||||
SHAFT_FLARE_M = float(argv[argv.index("--shaft-flare") + 1])
|
||||
if "--sole-drop" in argv:
|
||||
sole_drop = float(argv[argv.index("--sole-drop") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
LEATHER_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"boots per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{offset * 1000:.0f} mm, shaft-frac {SHAFT_FRAC}, "
|
||||
f"sole-drop {sole_drop * 1000:.0f} mm, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_boot_shell(body_dir, out_dir, body, offset, sole_drop)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
blender_author_buttondown.py (T-1089, buttondown_modern — per-body offset shell)
|
||||
|
||||
Button-down shirt authored per body via the offset-shell route. Imports
|
||||
blender_author_offset_shell.py as a module and reuses its scene/build/offset/
|
||||
solidify/logo-UV/export helpers; this file adds what the button-down needs
|
||||
beyond the t-shirt base:
|
||||
|
||||
* long-sleeve coverage — torso + torso_upper + BOTH full arms (upper+lower),
|
||||
with a wrist-plane cut derived from the lowerarm bone (SLEEVE_END_FRAC),
|
||||
replacing the base script's upper-arm short-sleeve cut
|
||||
* seam WELD after join — the body segments share exact duplicated boundary
|
||||
rings (probe: 24-38 coincident verts/seam, zero near-misses), so a
|
||||
remove-doubles pass gives continuous normals across segment seams ->
|
||||
crack-free outward offset and no internal solidify rims (UVs live on
|
||||
loops, so UV seams survive the weld; weights are identical by construction)
|
||||
* per-PIXEL region mask bake (barycentric-interpolated body-local positions)
|
||||
instead of the base per-face classification — the placket and cuff
|
||||
boundaries cut through the middle of low-poly faces
|
||||
* PAINTED albedo — texture-carried identity per the style pin: placket band
|
||||
with stitch lines, button dots, collar + cuff seam lines, over the flat
|
||||
toon fabric noise. Baked per body because each body archetype has its own
|
||||
UV atlas (the shell inherits body UVs).
|
||||
|
||||
Region convention (spec): collar=R (tint_0), body=G (tint_1),
|
||||
cuffs+placket=B (tint_2). A unused. Logo-capable OFF, but the UV2 chest
|
||||
channel is still authored (costs nothing; enables future brand variants).
|
||||
|
||||
Run (per-body only — this garment ships the per-body route):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_buttondown.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/buttondown_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.009]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb + <out_dir>/<body>_mask.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's painted albedo)
|
||||
<out_dir>/reference_mask.png (copy of average_m_mask.png)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
|
||||
(per-body authoring for offset shells).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import blender_author_offset_shell as base # noqa: E402
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Garment parameters (average_m metres; scaled per body by shoulder ratio)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
]
|
||||
|
||||
OFFSET_M = 0.009 # slimmer standoff than the tee (12 mm) — dress shirt
|
||||
CLOTH_THICKNESS_M = 0.003 # thinner cloth than the tee (4 mm)
|
||||
SLEEVE_END_FRAC = 0.92 # of lowerarm bone length — long sleeve, ends above wrist
|
||||
CUFF_START_FRAC = 0.62 # of lowerarm bone length — last ~38% of forearm = cuff (B)
|
||||
WELD_DIST = 0.0002 # merge duplicated segment-boundary rings (0.2 mm)
|
||||
|
||||
MASK_SIZE = 512
|
||||
ALBEDO_SIZE = 1024 # painted detail (buttons ~8 px radius) needs 1024
|
||||
|
||||
# Style dimensions on average_m (shoulder |x| = base._REF_SHOULDER_X);
|
||||
# scaled per body by shoulder_x ratio so proportions hold from child to heavy_m.
|
||||
PLACKET_HALF_M = 0.022 # placket half-width (4.4 cm total band)
|
||||
STITCH_W_M = 0.0025 # stitch/seam line half-width
|
||||
BUTTON_R_M = 0.0095 # button disc radius (chunky toon read)
|
||||
SEAM_HALF_M = 0.0022 # collar seam line half-height
|
||||
BUTTON_COUNT = 6
|
||||
FRONT_MIN_Y = 0.004 # body-local front test: y * FRONT_Y_SIGN > this
|
||||
|
||||
# Flat toon oxford base tone + painted feature colours (luma carries through
|
||||
# the luminance-preserving region tint in toon_garment.gdshader).
|
||||
FABRIC_RGB = (0.63, 0.64, 0.66)
|
||||
FABRIC_NOISE = 0.02
|
||||
PLACKET_BAND_MUL = 1.07 # subtle lift so the band reads under any tint
|
||||
STITCH_MUL = 0.52 # dark stitch lines
|
||||
SEAM_MUL = 0.55 # collar/cuff seam lines
|
||||
BUTTON_RGB_OUTER = (0.10, 0.10, 0.12)
|
||||
BUTTON_RGB_CORE = (0.24, 0.24, 0.27)
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[buttondown] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Threshold derivation (extends base.derive_thresholds with arm + style dims)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def derive_arm_thresholds(armature):
|
||||
"""Wrist cut planes + cuff start from the lowerarm bones (X-axis arms)."""
|
||||
bones = armature.data.bones
|
||||
la_l = bones.get("lowerarm_l")
|
||||
la_r = bones.get("lowerarm_r")
|
||||
if la_l is None or la_r is None:
|
||||
raise RuntimeError("lowerarm bones missing — cannot derive sleeve cut")
|
||||
|
||||
def along(b, frac):
|
||||
return b.head_local.x + frac * (b.tail_local.x - b.head_local.x)
|
||||
|
||||
cut_l = along(la_l, SLEEVE_END_FRAC) # left arm +x: delete x > cut_l
|
||||
cut_r = along(la_r, SLEEVE_END_FRAC) # right arm -x: delete x < cut_r
|
||||
cuff_x_abs = (abs(along(la_l, CUFF_START_FRAC))
|
||||
+ abs(along(la_r, CUFF_START_FRAC))) / 2.0
|
||||
log(f"sleeve: cut_l={cut_l:.3f} cut_r={cut_r:.3f} cuff |x|>={cuff_x_abs:.3f}")
|
||||
return {"cut_l": cut_l, "cut_r": cut_r, "cuff_x_abs": cuff_x_abs}
|
||||
|
||||
|
||||
def derive_style(armature):
|
||||
"""Scale the painted-detail dimensions by this body's shoulder ratio."""
|
||||
bones = armature.data.bones
|
||||
ua_l = bones.get("upperarm_l")
|
||||
ua_r = bones.get("upperarm_r")
|
||||
if ua_l is None or ua_r is None:
|
||||
s = 1.0
|
||||
else:
|
||||
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
s = shoulder_x / base._REF_SHOULDER_X
|
||||
log(f"style scale {s:.3f}")
|
||||
return {
|
||||
"placket_half": PLACKET_HALF_M * s,
|
||||
"stitch_w": STITCH_W_M * s,
|
||||
"button_r": BUTTON_R_M * s,
|
||||
"seam_half": SEAM_HALF_M * s,
|
||||
}
|
||||
|
||||
|
||||
def derive_buttons(shell, thr):
|
||||
"""Button Z positions: evenly spaced down the placket (collar -> hem)."""
|
||||
hem_z = min(v.co.z for v in shell.data.vertices)
|
||||
collar_z = thr["collar_z_min"]
|
||||
span = collar_z - hem_z
|
||||
z_top = collar_z - 0.05 * span
|
||||
z_bot = hem_z + 0.07 * span
|
||||
zs = list(np.linspace(z_top, z_bot, BUTTON_COUNT))
|
||||
log(f"buttons: {BUTTON_COUNT} @ z {z_bot:.3f}..{z_top:.3f} (hem {hem_z:.3f})")
|
||||
return {"button_zs": zs, "hem_z": hem_z}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: seam weld + wrist cut
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_seams(shell):
|
||||
"""Merge the duplicated segment-boundary rings into one continuous shell."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST)
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"seam weld: {before} -> {len(shell.data.vertices)} verts "
|
||||
f"({before - len(shell.data.vertices)} merged)")
|
||||
|
||||
|
||||
def wrist_cut(shell, thr):
|
||||
"""Delete sleeve verts beyond the wrist plane on each forearm."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = [v for v in bm.verts
|
||||
if v.co.x > thr["cut_l"] or v.co.x < thr["cut_r"]]
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"wrist cut removed {len(to_delete)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-pixel bake: region mask + painted albedo in one pass
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _gather_tris(shell):
|
||||
"""Fan-triangulate every face into (uv_a, uv_b, uv_c, co_a, co_b, co_c)."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
uvl = bm.loops.layers.uv[me.uv_layers[0].name] # UV0 = shared body atlas
|
||||
tris = []
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [np.array((lp[uvl].uv.x, lp[uvl].uv.y)) for lp in loops]
|
||||
cos = [np.array(lp.vert.co[:]) for lp in loops]
|
||||
for i in range(1, len(loops) - 1):
|
||||
tris.append((uvs[0], uvs[i], uvs[i + 1],
|
||||
cos[0], cos[i], cos[i + 1]))
|
||||
bm.free()
|
||||
return tris
|
||||
|
||||
|
||||
def _tri_cover(a, b, c, W, H):
|
||||
"""Pixel centres covered by UV triangle abc -> (ys, xs, w0, w1, w2)."""
|
||||
ax, ay = a[0] * (W - 1), a[1] * (H - 1)
|
||||
bx, by = b[0] * (W - 1), b[1] * (H - 1)
|
||||
cx, cy = c[0] * (W - 1), c[1] * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return None
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return None # degenerate (solidify rim) — no UV area to write
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
px = xs + 0.5
|
||||
py = ys + 0.5
|
||||
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
|
||||
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return None
|
||||
return (ys[inside].ravel(), xs[inside].ravel(),
|
||||
w0[inside].ravel(), w1[inside].ravel(), w2[inside].ravel())
|
||||
|
||||
|
||||
def _interp_pos(cover, co_a, co_b, co_c):
|
||||
_, _, w0, w1, w2 = cover
|
||||
return (w0[:, None] * co_a[None, :]
|
||||
+ w1[:, None] * co_b[None, :]
|
||||
+ w2[:, None] * co_c[None, :])
|
||||
|
||||
|
||||
def _classify_px(pos, thr):
|
||||
"""Vectorized region classification -> (N,4) one-hot RGBA rows."""
|
||||
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
||||
ax = np.abs(x)
|
||||
front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y
|
||||
cuff = ax >= thr["cuff_x_abs"]
|
||||
collar = (~cuff) & (z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"])
|
||||
placket = ((~cuff) & (~collar) & front
|
||||
& (ax <= thr["placket_half"]) & (z < thr["collar_z_min"]))
|
||||
rgba = np.zeros((len(x), 4), dtype=np.float32)
|
||||
rgba[:, 1] = 1.0 # default: body -> G
|
||||
rgba[collar] = (1.0, 0.0, 0.0, 0.0) # collar band -> R
|
||||
rgba[cuff | placket] = (0.0, 0.0, 1.0, 0.0) # cuffs + placket -> B
|
||||
return rgba
|
||||
|
||||
|
||||
def _paint_px(pos, rows, thr):
|
||||
"""Painted albedo: placket band + stitches, seams, button dots (in-place)."""
|
||||
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
||||
ax = np.abs(x)
|
||||
front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y
|
||||
below_collar = z < thr["collar_z_min"]
|
||||
|
||||
mul = np.ones(len(x), dtype=np.float32)
|
||||
band = front & below_collar & (ax <= thr["placket_half"])
|
||||
mul[band] = PLACKET_BAND_MUL
|
||||
stitch = front & below_collar & (np.abs(ax - thr["placket_half"])
|
||||
<= thr["stitch_w"])
|
||||
mul[stitch] = STITCH_MUL
|
||||
collar_seam = ((np.abs(z - thr["collar_z_min"]) <= thr["seam_half"])
|
||||
& (ax < thr["collar_x_abs"] * 1.8))
|
||||
mul[collar_seam] = SEAM_MUL
|
||||
cuff_seam = np.abs(ax - thr["cuff_x_abs"]) <= thr["stitch_w"]
|
||||
mul[cuff_seam] = SEAM_MUL
|
||||
out = rows * mul[:, None]
|
||||
|
||||
r = thr["button_r"]
|
||||
for bz in thr["button_zs"]:
|
||||
d2 = x * x + (z - bz) ** 2
|
||||
disc = front & (d2 <= r * r)
|
||||
out[disc] = BUTTON_RGB_OUTER
|
||||
core = front & (d2 <= (0.45 * r) ** 2)
|
||||
out[core] = BUTTON_RGB_CORE
|
||||
np.clip(out, 0.0, 1.0, out)
|
||||
return out
|
||||
|
||||
|
||||
def bake_maps(shell, thr, mask_path):
|
||||
"""One pass over the shell: bake <body>_mask.png + return painted albedo."""
|
||||
tris = _gather_tris(shell)
|
||||
|
||||
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
|
||||
mbuf[:, :, 1] = 1.0 # green background = main body (bilinear-bleed safe)
|
||||
|
||||
rng = np.random.default_rng(1090) # deterministic fabric noise
|
||||
fabric = np.array(FABRIC_RGB, dtype=np.float32)
|
||||
noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
|
||||
- 0.5) * 2.0 * FABRIC_NOISE
|
||||
abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
||||
|
||||
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
|
||||
cover = _tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
|
||||
if cover is not None:
|
||||
pos = _interp_pos(cover, co_a, co_b, co_c)
|
||||
mbuf[cover[0], cover[1], :] = _classify_px(pos, thr)
|
||||
cover = _tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
|
||||
if cover is not None:
|
||||
pos = _interp_pos(cover, co_a, co_b, co_c)
|
||||
abuf[cover[0], cover[1], :] = _paint_px(
|
||||
pos, abuf[cover[0], cover[1], :], thr)
|
||||
|
||||
tot = MASK_SIZE * MASK_SIZE
|
||||
log("mask texels: collar={:.1f}% body={:.1f}% cuff+placket={:.1f}%".format(
|
||||
100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
|
||||
100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
|
||||
100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
|
||||
|
||||
mask_img = bpy.data.images.new("garment_region_mask", MASK_SIZE, MASK_SIZE,
|
||||
alpha=True)
|
||||
mask_img.pixels.foreach_set(mbuf.reshape(-1))
|
||||
mask_img.update()
|
||||
mask_img.filepath_raw = mask_path
|
||||
mask_img.file_format = 'PNG'
|
||||
mask_img.save()
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
|
||||
argba = np.concatenate(
|
||||
[abuf, np.ones((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)],
|
||||
axis=2)
|
||||
albedo_img = bpy.data.images.new("base_albedo", ALBEDO_SIZE, ALBEDO_SIZE,
|
||||
alpha=False)
|
||||
albedo_img.pixels.foreach_set(argba.reshape(-1))
|
||||
albedo_img.update()
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Author one body
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_buttondown(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS # long-sleeve coverage set
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
|
||||
thr = base.derive_thresholds(armature) # collar/chest from bone landmarks
|
||||
thr.update(derive_arm_thresholds(armature))
|
||||
thr.update(derive_style(armature))
|
||||
|
||||
weld_seams(shell)
|
||||
wrist_cut(shell, thr)
|
||||
thr.update(derive_buttons(shell, thr))
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
base.solidify(shell, CLOTH_THICKNESS_M)
|
||||
|
||||
base.author_logo_uv(shell, thr) # UV2 before bake (mask/albedo use UV0)
|
||||
albedo_img = bake_maps(shell, thr, os.path.join(out_dir, f"{body}_mask.png"))
|
||||
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Entry
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> "
|
||||
"[--bodies a,b,c] [--offset M]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.1f} mm")
|
||||
|
||||
avg_albedo_stash = os.path.join(out_dir, "_tmp_avg_base_albedo.png")
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_buttondown(body_dir, out_dir, body, offset)
|
||||
if body == base.REFERENCE_BODY:
|
||||
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
||||
avg_albedo_stash)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc: # noqa: BLE001 — per-body isolation
|
||||
log(f"ERROR {body}: {exc}")
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Root sidecars: reference mask + albedo mirror the reference body.
|
||||
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png")
|
||||
if os.path.isfile(avg_albedo_stash):
|
||||
shutil.move(avg_albedo_stash, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"base_albedo.png = {base.REFERENCE_BODY}'s painted albedo")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,502 @@
|
||||
"""
|
||||
blender_author_cargo_pants.py (T-1089 wave 2, cargo_pants)
|
||||
|
||||
Authors full-length CARGO pants as per-body offset shells. Reuses
|
||||
blender_author_offset_shell.py as the base library (scene build, join,
|
||||
offset, solidify, GLB export) and blender_author_denim_pants.py as the
|
||||
proven bottoms companion (boundary WELD of coincident segment-seam rings,
|
||||
open-rim FLATTENING onto clean planes, per-body LegLandmarks, waist flare,
|
||||
hem cut, parked logo UV2) — the denim script's utilities are imported as a
|
||||
module, not re-derived.
|
||||
|
||||
What cargo adds, as reusable parameters:
|
||||
|
||||
* --straight: STRAIGHT-FIT boost — extra radial stand-off away from the
|
||||
per-z leg axis, ramping 0 at the knee to the full value at the ankle, so
|
||||
the lower leg does not taper with the calf (straight silhouette; also
|
||||
buys lower-leg clip clearance). Applied post-offset, pre-solidify.
|
||||
* TEXEL-level cargo feature painting (same one-field-drives-both design as
|
||||
denim: painted albedo and region mask always agree):
|
||||
- albedo: large outer-thigh CARGO POCKETS with button-down FLAPS
|
||||
(filled panels, border stitching, centre pleat, two flap
|
||||
buttons), outseam/inseam side seams, centre-front fly
|
||||
stitch, belt loops + waist button, waistband and hem border
|
||||
stitching — flat tone-on-tone utility look, identity
|
||||
carried by the texture (style pin: modern only).
|
||||
- mask: waistband -> R, legs -> G, pockets + flaps -> B
|
||||
(spec: waistband=R, legs=G, pockets+flaps=B).
|
||||
A SIGNED azimuth arc around the per-z leg axis (0 at the outer side,
|
||||
positive toward the front) places the pockets slightly forward-of-side
|
||||
and the flap buttons symmetrically — the same analytic-field approach as
|
||||
the denim script, generalised from unsigned to signed arc.
|
||||
* pocket proportions anchor to each body's own THIGH span (crotch ->
|
||||
knee) and hip half-width, so the pockets stay proportional across all
|
||||
11 bodies (child included) — the base.derive_thresholds philosophy.
|
||||
|
||||
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r; natural
|
||||
boundaries give the waist opening and ankle hems. Regions are painted, not
|
||||
modelled — no 3D pocket geometry (texture carries identity).
|
||||
|
||||
Usage (cargo_pants reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_cargo_pants.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/cargo_pants \
|
||||
[--bodies average_m,child,...] [--offset 0.013] [--hem-frac 1.0] \
|
||||
[--waist-flare 0.007] [--straight 0.010] [--base-rgb 0.36,0.37,0.29] \
|
||||
[--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module + the denim bottoms companion
|
||||
# (weld / rim-flatten / flare / landmarks utilities live there)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(name, fname):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, os.path.join(_HERE, fname))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load("offset_shell_base", "blender_author_offset_shell.py")
|
||||
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
|
||||
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_hips",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
]
|
||||
|
||||
HEM_FRAC = 1.0 # 1.0 = full leg to the ankle
|
||||
OFFSET_M = 0.013 # cargo sits a touch looser than jeans (0.012)
|
||||
STRAIGHT_M = 0.010 # straight-fit radial boost at the ankle (0 at knee)
|
||||
WAIST_FLARE_M = 0.007 # waistband rim stand-off (deep-crouch mitigation,
|
||||
# proven on jeans)
|
||||
TEX_SIZE = 1024 # painted pockets/seams need >512
|
||||
|
||||
# Vertical proportions — fractions of the garment span (waist_z - hem_z),
|
||||
# via denim.LegLandmarks.finalize (BAND/CUFF/SEAM fractions proven on jeans).
|
||||
# Pocket proportions — fractions of the THIGH span (crotch_z - knee_z).
|
||||
FLAP_TOP_FRAC = 0.18 # flap top below the crotch
|
||||
FLAP_H_FRAC = 0.14 # flap height
|
||||
POCKET_BOT_FRAC = 0.74 # pocket bottom below the crotch
|
||||
|
||||
# Horizontal proportions — signed azimuth ARC around the per-z leg axis
|
||||
# (metres on the reference body, scaled by the body's hip ratio lm.sh).
|
||||
POCKET_HALF_ARC_M = 0.058 # pocket half-width
|
||||
FLAP_EXTRA_ARC_M = 0.007 # flap overhangs the pocket by this much per side
|
||||
POCKET_FWD_BIAS_M = 0.010 # pocket centre sits slightly forward of the outseam
|
||||
BUTTON_R_M = 0.0075 # flap button radius
|
||||
LOOP_X_FRACS = (0.55, 1.30) # belt-loop |x| positions (fractions of hip_x)
|
||||
LOOP_W_M = 0.016 # belt loop width (m, scaled by hip ratio)
|
||||
|
||||
# Utility olive/grey style (sRGB floats; flat toon-friendly, tone-on-tone).
|
||||
CARGO_RGB = (0.360, 0.370, 0.290) # utility olive-grey
|
||||
THREAD_RGB = (0.225, 0.235, 0.185) # tone-on-tone darker stitching
|
||||
BUTTON_RGB = (0.170, 0.160, 0.140) # matte button
|
||||
ALBEDO_NOISE = 0.020 # +/- woven jitter
|
||||
POCKET_SHADE = 0.93 # pocket panel albedo darkening
|
||||
FLAP_SHADE = 0.85 # flap panel albedo darkening
|
||||
LOOP_SHADE = 0.80 # belt-loop albedo darkening
|
||||
PLAIN = False # --plain: skip pockets/stitch/button paint
|
||||
NOISE_SEED = 3089
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: straight-fit boost (cargo's reusable fit parameter)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def straight_boost(shell, lm, boost):
|
||||
"""Extra RADIAL stand-off away from the per-z leg axis below the knee,
|
||||
ramping linearly from 0 at the knee to `boost` at the ankle. The body
|
||||
calf tapers; pushing the shell out progressively keeps the pant leg
|
||||
straight (cargo silhouette) instead of hugging the calf. Applied
|
||||
post-offset, pre-solidify; weights/UVs are untouched."""
|
||||
if boost <= 0.0:
|
||||
return
|
||||
knee_z = float(lm.leg_z_pts[1])
|
||||
ankle_z = float(lm.leg_z_pts[0])
|
||||
span = max(knee_z - ankle_z, 1e-6)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
n = 0
|
||||
for v in bm.verts:
|
||||
z = v.co.z
|
||||
if z >= knee_z:
|
||||
continue
|
||||
t = min((knee_z - z) / span, 1.0)
|
||||
side = 1.0 if v.co.x >= 0.0 else -1.0
|
||||
cx = side * float(np.interp(z, lm.leg_z_pts, lm.leg_x_pts))
|
||||
cy = float(np.interp(z, lm.leg_z_pts, lm.leg_y_pts))
|
||||
dx = v.co.x - cx
|
||||
dy = v.co.y - cy
|
||||
r = (dx * dx + dy * dy) ** 0.5
|
||||
if r > 1e-6:
|
||||
v.co.x += boost * t * dx / r
|
||||
v.co.y += boost * t * dy / r
|
||||
n += 1
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"straight-fit boost: {n} verts, +{boost * 1000:.1f} mm radial at "
|
||||
f"ankle (ramped from knee z={knee_z:.3f})")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Cargo feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _cargo_field(px, py, pz, lm):
|
||||
"""Evaluate cargo features at texel 3D positions (numpy arrays).
|
||||
|
||||
Returns a dict of bool arrays. `pocket`/`flap` feed the mask B channel;
|
||||
the stitch/fill features feed the albedo only.
|
||||
"""
|
||||
w2 = lm.seam_w * 0.5
|
||||
front = py * FRONT_Y_SIGN > 0.004
|
||||
in_band = pz >= lm.band_z
|
||||
mid = (~in_band) & (pz > lm.hem_z + 0.5 * lm.cuff_h)
|
||||
|
||||
# Per-z leg axis, mirrored by x sign; SIGNED azimuth arc around it
|
||||
# (0 at the outer side, positive toward the front, +/-pi*r at the inseam).
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
|
||||
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
r = np.hypot(dx, dy) + 1e-9
|
||||
theta = np.arctan2(dy * FRONT_Y_SIGN / r, np.clip(dx * side / r, -1.0, 1.0))
|
||||
sarc = theta * r # signed arc distance from the outer direction
|
||||
|
||||
# Side seams: outseam on the outer azimuth; inseam opposite, below crotch.
|
||||
outseam = mid & (np.abs(sarc) < w2)
|
||||
arc_in = (np.pi - np.abs(theta)) * r
|
||||
inseam = mid & (arc_in < w2) \
|
||||
& (pz < lm.crotch_z - 0.01 * lm.span / denim._REF_SPAN)
|
||||
|
||||
# Centre-front fly stitch (slightly off-centre, like the jeans J-front).
|
||||
fly = front & mid & (np.abs(px - 0.012 * lm.sh) < w2) \
|
||||
& (pz > lm.crotch_z + 0.015 * lm.span / denim._REF_SPAN)
|
||||
|
||||
# --- cargo pocket + flap (outer thigh, slightly forward-of-side) --------
|
||||
knee_z = float(lm.leg_z_pts[1])
|
||||
thigh = max(lm.crotch_z - knee_z, 1e-6)
|
||||
flap_top = lm.crotch_z - FLAP_TOP_FRAC * thigh
|
||||
flap_bot = flap_top - FLAP_H_FRAC * thigh
|
||||
pocket_bot = lm.crotch_z - POCKET_BOT_FRAC * thigh
|
||||
p_arc = POCKET_HALF_ARC_M * lm.sh
|
||||
f_arc = p_arc + FLAP_EXTRA_ARC_M * lm.sh
|
||||
a = sarc - POCKET_FWD_BIAS_M * lm.sh # pocket-centred signed arc
|
||||
|
||||
pocket = (np.abs(a) < p_arc) & (pz <= flap_top) & (pz >= pocket_bot)
|
||||
flap = (np.abs(a) < f_arc) & (pz <= flap_top) & (pz >= flap_bot)
|
||||
|
||||
# Pocket border stitching (sides + bottom), centre pleat, flap edge.
|
||||
p_side = (np.abs(np.abs(a) - p_arc) < w2) & (pz <= flap_top) \
|
||||
& (pz >= pocket_bot)
|
||||
p_bottom = (np.abs(pz - pocket_bot) < w2) & (np.abs(a) < p_arc)
|
||||
pleat = (np.abs(a) < w2) & (pz < flap_bot - 2.0 * w2) & (pz >= pocket_bot)
|
||||
f_edge = flap & ((np.abs(np.abs(a) - f_arc) < w2)
|
||||
| (np.abs(pz - flap_bot) < w2))
|
||||
pkt_stitch = p_side | p_bottom | pleat | f_edge
|
||||
|
||||
# Two flap buttons, symmetric about the pocket centre.
|
||||
btn_r = BUTTON_R_M * lm.sh
|
||||
btn_z = flap_bot + 0.30 * (flap_top - flap_bot)
|
||||
buttons = np.zeros_like(front)
|
||||
for bx in (-0.45 * f_arc, 0.45 * f_arc):
|
||||
buttons |= np.hypot(a - bx, pz - btn_z) < btn_r
|
||||
|
||||
# Border stitching: waistband seam + hem stitch.
|
||||
wstitch = np.abs(pz - lm.band_z) < w2
|
||||
hemstitch = np.abs(pz - lm.cuff_top) < w2
|
||||
|
||||
return {
|
||||
"front": front, "in_band": in_band,
|
||||
"seams": outseam | inseam | fly,
|
||||
"wstitch": wstitch, "hemstitch": hemstitch,
|
||||
"pocket": pocket, "flap": flap,
|
||||
"pkt_stitch": pkt_stitch, "buttons": buttons,
|
||||
}
|
||||
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
f = _cargo_field(px, py, pz, lm)
|
||||
|
||||
# --- albedo -------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = CARGO_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
|
||||
if not PLAIN:
|
||||
# Belt loops: darkened bands on the waistband.
|
||||
loop_w = LOOP_W_M * lm.sh
|
||||
loops = np.zeros(n, dtype=bool)
|
||||
for fx in LOOP_X_FRACS:
|
||||
loops |= np.abs(np.abs(px) - fx * lm.hip_x) < loop_w * 0.5
|
||||
loops |= (~f["front"]) & (np.abs(px) < loop_w * 0.5) # centre-back
|
||||
loops &= f["in_band"]
|
||||
alb[loops, :3] *= LOOP_SHADE
|
||||
|
||||
# Panel fills first, stitch lines on top.
|
||||
alb[f["pocket"] & ~f["flap"], :3] *= POCKET_SHADE
|
||||
alb[f["flap"], :3] *= FLAP_SHADE
|
||||
|
||||
thread = f["seams"] | f["wstitch"] | f["hemstitch"] | f["pkt_stitch"]
|
||||
alb[thread, 0] = THREAD_RGB[0]
|
||||
alb[thread, 1] = THREAD_RGB[1]
|
||||
alb[thread, 2] = THREAD_RGB[2]
|
||||
|
||||
# Flap buttons + waist button.
|
||||
btn = f["buttons"] | (f["front"] & (
|
||||
np.hypot(px, pz - (lm.band_z + 0.5 * lm.band_h)) < 0.009 * lm.sh))
|
||||
alb[btn, 0] = BUTTON_RGB[0]
|
||||
alb[btn, 1] = BUTTON_RGB[1]
|
||||
alb[btn, 2] = BUTTON_RGB[2]
|
||||
|
||||
# --- region mask: waistband R / legs G / pockets+flaps B -----------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_b = (f["pocket"] | f["flap"]) & ~f["in_band"]
|
||||
is_g = ~(f["in_band"] | is_b)
|
||||
mask[f["in_band"], 0] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[is_g, 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the cargo field, write albedo + mask together (same rasterizer
|
||||
contract as the denim companion, pointed at the cargo painter)."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = CARGO_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = legs green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"cargo_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"cargo_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_cargo_shell(body_dir, out_dir, body, offset, hem_frac):
|
||||
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
|
||||
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell)
|
||||
|
||||
zs = [v.co.z for v in shell.data.vertices]
|
||||
waist_z, ankle_z = max(zs), min(zs)
|
||||
lm = denim.LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
|
||||
|
||||
hem_z = denim.hem_cut(shell, lm, hem_frac)
|
||||
ankle_plane = float(lm.leg_z_pts[0]) if hem_frac >= 0.999 else hem_z
|
||||
waist_plane, ankle_plane = denim.flatten_open_rims(shell, ankle_plane)
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
straight_boost(shell, lm, STRAIGHT_M)
|
||||
denim.waist_flare(shell, waist_plane,
|
||||
denim.BAND_FRAC * (waist_plane - ankle_plane),
|
||||
WAIST_FLARE_M)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
denim.clamp_waist_residue(shell, waist_plane)
|
||||
|
||||
# Landmarks reference the CLEAN rims (band under the flattened waist edge,
|
||||
# hem stitch above the flattened ankle rim).
|
||||
lm.waist_z = waist_plane
|
||||
lm.finalize(ankle_plane)
|
||||
denim.author_parked_uv2(shell)
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global HEM_FRAC, CARGO_RGB, PLAIN, WAIST_FLARE_M, STRAIGHT_M
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--hem-frac F] [--waist-flare M] [--straight M] "
|
||||
"[--base-rgb r,g,b] [--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--hem-frac" in argv:
|
||||
HEM_FRAC = float(argv[argv.index("--hem-frac") + 1])
|
||||
if "--waist-flare" in argv:
|
||||
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
|
||||
if "--straight" in argv:
|
||||
STRAIGHT_M = float(argv[argv.index("--straight") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
CARGO_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"cargo per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{offset * 1000:.0f} mm, straight {STRAIGHT_M * 1000:.0f} mm, "
|
||||
f"hem-frac {HEM_FRAC}, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_cargo_shell(body_dir, out_dir, body, offset, HEM_FRAC)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,659 @@
|
||||
"""
|
||||
blender_author_denim_pants.py (T-1089, jeans_modern + denim pants family)
|
||||
|
||||
Authors full-length DENIM pants as per-body offset shells, reusing
|
||||
blender_author_offset_shell.py as a library (scene build, join, offset,
|
||||
solidify, GLB export). Sibling companions already cover the plain lower-body
|
||||
family (blender_author_offset_shell_legs.py — shorts, 2-region;
|
||||
blender_author_lower_shell.py — formal, crease lines); this one adds what
|
||||
denim needs and they don't provide, as reusable parameters:
|
||||
|
||||
* TEXEL-level feature painting: every UV0 triangle is rasterized with
|
||||
barycentric-interpolated 3D positions, and an analytic denim feature
|
||||
field is evaluated per texel. ONE field evaluation drives BOTH outputs,
|
||||
so the painted albedo and the region mask always agree:
|
||||
- albedo: painted seam thread lines (outseam/inseam azimuth around the
|
||||
per-z leg axis, centre-front fly, back yoke V), front pocket
|
||||
arcs, back pocket outlines, belt loops, waist button,
|
||||
waist/cuff border stitching — flat toon-friendly, identity
|
||||
carried by the texture (style pin: modern only).
|
||||
- mask: waistband -> R, legs -> G, seams + cuff band -> B
|
||||
(spec: waistband=R, legs=G, seams/cuffs=B). Seam LINES are
|
||||
in the B channel, not just the cuff band, so contrast-thread
|
||||
recoloring works (toon_garment.gdshader channel-blends).
|
||||
* boundary WELD before offsetting — the waist join (hips<->leg_upper) and
|
||||
knee join (leg_upper<->leg_lower) carry duplicated boundary-ring verts
|
||||
per segment; offsetting un-welded rings along diverging normals opens
|
||||
cracks, so coincident verts are merged first (weights identical by
|
||||
origin, so skinning is unaffected).
|
||||
* --hem-frac: fraction of the hip->ankle span covered, measured up from the
|
||||
ankle (1.0 = full length to the ankle = jeans; lower values give cropped
|
||||
variants; cut rims are capped by Solidify(use_rim) like the base sleeves).
|
||||
* a parked logo_uv TEXCOORD_1 layer (all UVs at (2,2)): pants are not
|
||||
logo-capable, but toon_garment.gdshader samples UV2 unconditionally, so
|
||||
every multi_region garment ships a well-defined logo channel.
|
||||
* open-rim FLATTENING: the segment splitter cuts along weight thresholds,
|
||||
so the waist and ankle boundary rings are jagged "teeth" (~5-6 cm deep on
|
||||
average_m); boundary verts are pulled onto clean planes (waist ring down
|
||||
to its own valley, ankle rings onto the ankle-joint plane) pre-offset.
|
||||
* --waist-flare: extra feathered radial stand-off at the waistband rim —
|
||||
deep-crouch waist-fold clip mitigation (QA evidence, peasant + jeans).
|
||||
|
||||
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r. Natural
|
||||
boundaries give the waist opening (seg_hips top rim) and the ankle hems
|
||||
(seg_leg_lower bottom) for free.
|
||||
|
||||
All cut/mask/paint parameters derive PER BODY from that body's own bone
|
||||
landmarks (thigh_l/calf_l line) and measured mesh extents (waist rim, ankle
|
||||
rim, crotch = seg_hips lowest ring), scaled by the body's garment span and
|
||||
hip half-width — the same proportional-ratio philosophy as
|
||||
base.derive_thresholds, so the denim details stay consistent across all 11
|
||||
bodies. Per-body mode only (offset shells author per body, Q-060).
|
||||
|
||||
Usage (jeans_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_denim_pants.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/jeans_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 1.0] \
|
||||
[--waist-flare 0.007] [--base-rgb 0.24,0.32,0.45] [--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module (shared machinery)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
|
||||
base = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(base)
|
||||
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_hips",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
]
|
||||
|
||||
HEM_FRAC = 1.0 # 1.0 = full leg to the ankle (jeans)
|
||||
WELD_DIST = 5e-4 # boundary-ring weld tolerance (0.5 mm)
|
||||
TEX_SIZE = 1024 # albedo + mask resolution (painted seams need >512)
|
||||
WAIST_FLARE_M = 0.007 # extra radial stand-off at the waistband rim
|
||||
# (deep-crouch waist-fold mitigation, feathered)
|
||||
|
||||
# Vertical proportions — fractions of the garment span (waist_z - hem_z).
|
||||
BAND_FRAC = 0.055 # waistband height (R region)
|
||||
CUFF_FRAC = 0.032 # cuff band height (B region)
|
||||
SEAM_W_FRAC = 0.0095 # painted seam line width
|
||||
YOKE_DROP_FRAC = 0.055 # back yoke centre depth below the waistband
|
||||
YOKE_RISE_FRAC = 0.030 # yoke V rise toward the sides
|
||||
BPOCKET_TOP_FRAC = 0.075 # back pocket top below the waistband
|
||||
BPOCKET_HH_FRAC = 0.048 # back pocket half-height
|
||||
FPOCKET_RZ_FRAC = 0.14 # front pocket arc vertical radius
|
||||
|
||||
# Horizontal proportions — fractions of the hip half-width (|thigh head x|).
|
||||
BPOCKET_CX_FRAC = 0.80 # back pocket centre
|
||||
BPOCKET_HW_FRAC = 0.52 # back pocket half-width
|
||||
FPOCKET_CX_FRAC = 1.30 # front pocket arc centre (near the outseam corner)
|
||||
FPOCKET_RX_FRAC = 0.75 # front pocket arc horizontal radius
|
||||
LOOP_X_FRACS = (0.55, 1.30) # belt-loop |x| positions (front + back pairs)
|
||||
LOOP_W_M = 0.016 # belt loop width (m, scaled by hip width)
|
||||
|
||||
# Denim style (sRGB floats; saved as-is — matches the proven base pipeline).
|
||||
DENIM_RGB = (0.240, 0.320, 0.450) # indigo denim
|
||||
THREAD_RGB = (0.800, 0.620, 0.340) # contrast stitching thread
|
||||
BUTTON_RGB = (0.850, 0.720, 0.480) # waist button metal
|
||||
ALBEDO_NOISE = 0.020 # +/- woven jitter
|
||||
CUFF_SHADE = 0.90 # cuff band albedo darkening
|
||||
LOOP_SHADE = 0.80 # belt-loop albedo darkening
|
||||
PLAIN = False # --plain: skip thread/pocket/button paint
|
||||
NOISE_SEED = 2089
|
||||
|
||||
# Reference proportions (average_m) the fractions were calibrated against.
|
||||
_REF_SPAN = 1.007 # waist rim z (1.093) - ankle z (0.086)
|
||||
_REF_HIP_X = 0.0906 # |thigh_l head x|
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class LegLandmarks:
|
||||
"""Cut/mask/paint parameters derived from one body's bones + mesh."""
|
||||
|
||||
def __init__(self, armature, waist_z, ankle_z, crotch_z):
|
||||
bones = armature.data.bones
|
||||
thigh = bones.get("thigh_l")
|
||||
calf = bones.get("calf_l")
|
||||
if thigh is None or calf is None:
|
||||
raise RuntimeError("thigh_l/calf_l missing — not the 65-bone rig?")
|
||||
self.waist_z = waist_z
|
||||
self.ankle_z = ankle_z
|
||||
self.crotch_z = crotch_z
|
||||
self.hip_x = abs(thigh.head_local.x)
|
||||
# Leg axis control points (z-increasing) for azimuth seam placement.
|
||||
# x values are the +x (left) leg; the right leg mirrors via sign.
|
||||
self.leg_z_pts = np.array(
|
||||
[calf.tail_local.z, calf.head_local.z, thigh.head_local.z])
|
||||
self.leg_x_pts = np.array(
|
||||
[abs(calf.tail_local.x), abs(calf.head_local.x),
|
||||
abs(thigh.head_local.x)])
|
||||
self.leg_y_pts = np.array(
|
||||
[calf.tail_local.y, calf.head_local.y, thigh.head_local.y])
|
||||
self.hem_z = ankle_z # finalized after the hem cut
|
||||
|
||||
def finalize(self, hem_z):
|
||||
self.hem_z = hem_z
|
||||
self.span = self.waist_z - self.hem_z
|
||||
self.sh = self.hip_x / _REF_HIP_X
|
||||
self.band_h = BAND_FRAC * self.span
|
||||
self.cuff_h = CUFF_FRAC * self.span
|
||||
self.seam_w = SEAM_W_FRAC * self.span
|
||||
self.band_z = self.waist_z - self.band_h
|
||||
self.cuff_top = self.hem_z + self.cuff_h
|
||||
log(f"landmarks: waist={self.waist_z:.3f} hem={self.hem_z:.3f} "
|
||||
f"crotch={self.crotch_z:.3f} hip_x={self.hip_x:.3f} "
|
||||
f"band_z={self.band_z:.3f} cuff_top={self.cuff_top:.3f} "
|
||||
f"seam_w={self.seam_w * 1000:.1f}mm")
|
||||
|
||||
|
||||
def probe_hips_bounds(body_dir):
|
||||
"""Import seg_hips alone to measure the crotch (its lowest ring) exactly."""
|
||||
base.clear_scene()
|
||||
objs = base.import_glb(os.path.join(body_dir, "seg_hips.glb"))
|
||||
zs = []
|
||||
for o in objs:
|
||||
if base.is_body_mesh(o):
|
||||
zs.extend(v.co.z for v in o.data.vertices)
|
||||
if not zs:
|
||||
raise RuntimeError("seg_hips.glb yielded no skinned mesh")
|
||||
return min(zs), max(zs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: weld + hem cut
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_boundaries(shell):
|
||||
"""Merge coincident segment-boundary verts so the offset can't open cracks."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_DIST)
|
||||
merged = before - len(bm.verts)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"welded segment boundaries: {merged} verts merged "
|
||||
f"({before} -> {len(me.vertices)})")
|
||||
|
||||
|
||||
def flatten_open_rims(shell, ankle_plane):
|
||||
"""Pull the open boundary rings onto clean planes (pre-offset).
|
||||
|
||||
The body segment splitter cuts along weight thresholds, not edge loops, so
|
||||
the seg_hips top edge and the seg_leg_lower ankle edges are jagged rings
|
||||
of "teeth" (~5-6 cm on average_m). On the skin this hides under the
|
||||
neighbouring segment; on a garment shell it becomes a ragged silhouette.
|
||||
|
||||
The top ring's verts are pulled DOWN to the ring's own valley (deepest
|
||||
notch) — a clean straight waist edge without inventing coverage. The
|
||||
bottom rings' verts are moved onto `ankle_plane` (the ankle joint for
|
||||
full-length pants, or the hem-cut plane for cropped variants) — a clean
|
||||
straight hem AT the ankle. Only boundary verts move; interior verts are
|
||||
untouched, and weights/UVs ride along, so skinning and painting are
|
||||
unaffected. Returns (waist_plane, ankle_plane).
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1: # open boundary
|
||||
boundary.update(v.index for v in e.verts)
|
||||
zs = [bm.verts[i].co.z for i in boundary]
|
||||
z_mid = (min(zs) + max(zs)) / 2.0
|
||||
top = [i for i in boundary if bm.verts[i].co.z > z_mid]
|
||||
bottom = [i for i in boundary if bm.verts[i].co.z <= z_mid]
|
||||
waist_plane = min(bm.verts[i].co.z for i in top)
|
||||
teeth_top = max(bm.verts[i].co.z for i in top) - waist_plane
|
||||
teeth_bot_lo = min(bm.verts[i].co.z for i in bottom)
|
||||
teeth_bot_hi = max(bm.verts[i].co.z for i in bottom)
|
||||
for i in top:
|
||||
bm.verts[i].co.z = waist_plane
|
||||
for i in bottom:
|
||||
bm.verts[i].co.z = ankle_plane
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened open rims: waist ring ({len(top)} verts, teeth "
|
||||
f"{teeth_top:.3f} m) -> {waist_plane:.3f}; ankle rings "
|
||||
f"({len(bottom)} verts, z {teeth_bot_lo:.3f}..{teeth_bot_hi:.3f}) "
|
||||
f"-> {ankle_plane:.3f}")
|
||||
return waist_plane, ankle_plane
|
||||
|
||||
|
||||
def waist_flare(shell, waist_plane, band_h, flare):
|
||||
"""Extra RADIAL stand-off at the waistband rim, feathered over 2x the band
|
||||
height (pre-solidify). QA evidence: in deep crouch the lower-back skin
|
||||
folds over the waist rim (the peasant set's known 'deep-crouch waist gap',
|
||||
worst on small bodies). Flaring the band outward gives the fold room and
|
||||
reads as a natural jeans waistband stand-off."""
|
||||
if flare <= 0.0:
|
||||
return
|
||||
z0 = waist_plane - 2.0 * band_h
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
n = 0
|
||||
for v in bm.verts:
|
||||
if v.co.z > z0:
|
||||
t = min((v.co.z - z0) / (2.0 * band_h), 1.0)
|
||||
nx, ny = v.normal.x, v.normal.y
|
||||
mag = (nx * nx + ny * ny) ** 0.5
|
||||
if mag > 1e-6:
|
||||
v.co.x += flare * t * nx / mag
|
||||
v.co.y += flare * t * ny / mag
|
||||
n += 1
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"waist flare: {n} verts, +{flare * 1000:.1f} mm radial at rim "
|
||||
f"(feathered from z={z0:.3f})")
|
||||
|
||||
|
||||
def clamp_waist_residue(shell, waist_plane):
|
||||
"""Post-solidify safety clamp: any interior tooth verts still above the
|
||||
waist plane (multi-triangle teeth) get squashed onto it."""
|
||||
me = shell.data
|
||||
n = 0
|
||||
for v in me.vertices:
|
||||
if v.co.z > waist_plane:
|
||||
v.co.z = waist_plane
|
||||
n += 1
|
||||
me.update()
|
||||
if n:
|
||||
log(f"clamped {n} residual waist verts -> {waist_plane:.3f}")
|
||||
|
||||
|
||||
def hem_cut(shell, lm, hem_frac):
|
||||
"""Trim the legs below the hem plane. 1.0 keeps the full ankle length."""
|
||||
if hem_frac >= 0.999:
|
||||
return lm.ankle_z
|
||||
hip_z = float(lm.leg_z_pts[-1])
|
||||
hem_z = lm.ankle_z + (1.0 - hem_frac) * (hip_z - lm.ankle_z)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts if v.co.z < hem_z]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"hem cut at z={hem_z:.3f}: removed {len(doomed)} verts")
|
||||
return hem_z
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Denim feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _seam_field(px, py, pz, lm):
|
||||
"""Evaluate denim features at texel 3D positions (numpy arrays).
|
||||
|
||||
Returns bool arrays (seams, thread, in_cuff, in_band, front):
|
||||
`seams` feeds the mask B channel; `thread` is every painted stitch line.
|
||||
"""
|
||||
w2 = lm.seam_w * 0.5
|
||||
front = py * FRONT_Y_SIGN > 0.004
|
||||
backside = py * FRONT_Y_SIGN < -0.004
|
||||
in_band = pz >= lm.band_z
|
||||
in_cuff = pz <= lm.cuff_top
|
||||
mid = (~in_band) & (~in_cuff)
|
||||
|
||||
# Per-z leg axis, mirrored by x sign.
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
|
||||
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
r = np.hypot(dx, dy) + 1e-9
|
||||
|
||||
# Outseam: azimuth toward the outer (+/-x) direction; constant metric
|
||||
# width via arc distance. Inseam: inner direction, below the crotch only.
|
||||
arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
|
||||
outseam = mid & (arc_out < w2)
|
||||
arc_in = np.arccos(np.clip(-dx * side / r, -1.0, 1.0)) * r
|
||||
inseam = mid & (arc_in < w2) & (pz < lm.crotch_z - 0.01 * lm.span / _REF_SPAN)
|
||||
|
||||
# Centre-front fly stitch (slightly off-centre, classic J-front).
|
||||
fly = front & mid & (np.abs(px - 0.012 * lm.sh) < w2) \
|
||||
& (pz > lm.crotch_z + 0.015 * lm.span / _REF_SPAN)
|
||||
|
||||
# Back yoke: shallow V, higher toward the sides.
|
||||
yoke_z = (lm.band_z - YOKE_DROP_FRAC * lm.span
|
||||
+ YOKE_RISE_FRAC * lm.span
|
||||
* np.minimum(np.abs(px) / (1.5 * lm.hip_x), 1.0))
|
||||
yoke = backside & mid & (np.abs(pz - yoke_z) < w2) \
|
||||
& (np.abs(px) < 1.6 * lm.hip_x)
|
||||
|
||||
seams = outseam | inseam | fly | yoke
|
||||
|
||||
# Stitch-only lines (albedo, not mask): waist + cuff border stitching.
|
||||
wstitch = np.abs(pz - lm.band_z) < w2
|
||||
cstitch = np.abs(pz - lm.cuff_top) < w2
|
||||
|
||||
# Back pocket outlines (rectangle rings on the seat).
|
||||
bhw = BPOCKET_HW_FRAC * lm.hip_x
|
||||
bhh = BPOCKET_HH_FRAC * lm.span
|
||||
bcz = lm.band_z - BPOCKET_TOP_FRAC * lm.span - bhh
|
||||
adx = np.abs(np.abs(px) - BPOCKET_CX_FRAC * lm.hip_x)
|
||||
adz = np.abs(pz - bcz)
|
||||
bpocket = backside & (adx < bhw + w2) & (adz < bhh + w2) \
|
||||
& ~((adx < bhw - w2) & (adz < bhh - w2))
|
||||
|
||||
# Front pocket arcs (quarter-ellipse from waistband toward the outseam).
|
||||
fcx = FPOCKET_CX_FRAC * lm.hip_x
|
||||
frx = FPOCKET_RX_FRAC * lm.hip_x
|
||||
frz = FPOCKET_RZ_FRAC * lm.span
|
||||
ex = (np.abs(px) - fcx) / frx
|
||||
ez = (pz - lm.band_z) / frz
|
||||
fdist = (np.sqrt(ex * ex + ez * ez) - 1.0) * (0.5 * (frx + frz))
|
||||
fpocket = front & (np.abs(fdist) < w2) & (pz <= lm.band_z) \
|
||||
& (np.abs(px) <= fcx)
|
||||
|
||||
thread = seams | wstitch | cstitch | bpocket | fpocket
|
||||
return seams, thread, in_cuff, in_band, front
|
||||
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
seams, thread, in_cuff, in_band, front = _seam_field(px, py, pz, lm)
|
||||
|
||||
# --- albedo ------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = DENIM_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
alb[in_cuff, :3] *= CUFF_SHADE
|
||||
|
||||
if not PLAIN:
|
||||
# Belt loops: darkened denim bands on the waistband.
|
||||
loop_w = LOOP_W_M * lm.sh
|
||||
loops = np.zeros(n, dtype=bool)
|
||||
for fx in LOOP_X_FRACS:
|
||||
loops |= np.abs(np.abs(px) - fx * lm.hip_x) < loop_w * 0.5
|
||||
loops |= (~front) & (np.abs(px) < loop_w * 0.5) # centre-back loop
|
||||
loops &= in_band
|
||||
alb[loops, :3] *= LOOP_SHADE
|
||||
|
||||
alb[thread, 0] = THREAD_RGB[0]
|
||||
alb[thread, 1] = THREAD_RGB[1]
|
||||
alb[thread, 2] = THREAD_RGB[2]
|
||||
|
||||
# Waist button (front, mid-band).
|
||||
btn = front & (np.hypot(px, pz - (lm.band_z + 0.5 * lm.band_h))
|
||||
< 0.009 * lm.sh)
|
||||
alb[btn, 0] = BUTTON_RGB[0]
|
||||
alb[btn, 1] = BUTTON_RGB[1]
|
||||
alb[btn, 2] = BUTTON_RGB[2]
|
||||
|
||||
# --- region mask: waistband R / legs G / seams+cuffs B ------------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_b = (in_cuff | seams) & ~in_band
|
||||
is_g = ~(in_band | is_b)
|
||||
mask[in_band, 0] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[is_g, 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the denim field, write albedo + mask together."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = DENIM_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = legs green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"denim_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"denim_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parked logo UV2 (pants are not logo-capable; the shader still samples UV2)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_parked_uv2(shell):
|
||||
me = shell.data
|
||||
while len(me.uv_layers) > 1:
|
||||
me.uv_layers.remove(me.uv_layers[-1])
|
||||
me.uv_layers.new(name="logo_uv")
|
||||
me.uv_layers.active = me.uv_layers[0]
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
uvl = bm.loops.layers.uv.get("logo_uv")
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
loop[uvl].uv = (2.0, 2.0)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log("logo UV2 authored fully parked (not logo-capable)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_denim_shell(body_dir, out_dir, body, offset, hem_frac):
|
||||
crotch_z, _hips_top = probe_hips_bounds(body_dir)
|
||||
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
weld_boundaries(shell)
|
||||
|
||||
zs = [v.co.z for v in shell.data.vertices]
|
||||
waist_z, ankle_z = max(zs), min(zs)
|
||||
lm = LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
|
||||
|
||||
hem_z = hem_cut(shell, lm, hem_frac)
|
||||
# Full-length pants hem AT the ankle joint (calf tail); cropped variants
|
||||
# hem at the cut plane.
|
||||
ankle_plane = float(lm.leg_z_pts[0]) if hem_frac >= 0.999 else hem_z
|
||||
waist_plane, ankle_plane = flatten_open_rims(shell, ankle_plane)
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
waist_flare(shell, waist_plane,
|
||||
BAND_FRAC * (waist_plane - ankle_plane), WAIST_FLARE_M)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
clamp_waist_residue(shell, waist_plane)
|
||||
|
||||
# Landmarks reference the CLEAN rims (band under the flattened waist edge,
|
||||
# cuff above the flattened hem).
|
||||
lm.waist_z = waist_plane
|
||||
lm.finalize(ankle_plane)
|
||||
author_parked_uv2(shell)
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global HEM_FRAC, DENIM_RGB, PLAIN, WAIST_FLARE_M
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--hem-frac F] [--waist-flare M] "
|
||||
"[--base-rgb r,g,b] [--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = base.PER_BODY_OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--hem-frac" in argv:
|
||||
HEM_FRAC = float(argv[argv.index("--hem-frac") + 1])
|
||||
if "--waist-flare" in argv:
|
||||
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
DENIM_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"denim per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{offset * 1000:.0f} mm, hem-frac {HEM_FRAC}, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_denim_shell(body_dir, out_dir, body, offset, HEM_FRAC)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,590 @@
|
||||
"""
|
||||
blender_author_hoodie.py (T-1089 — hoodie_modern, per-body offset-shell)
|
||||
|
||||
Hooded-top companion to blender_author_offset_shell.py: reuses the base
|
||||
module's segment join / bone-ratio thresholds / offset / solidify / logo-UV2 /
|
||||
export machinery and layers the hoodie-specific geometry + texture identity on
|
||||
top. Everything spatial is derived from bone landmarks (ratios of neck_01
|
||||
length, shoulder |x|, spine span) so the same parameters produce a
|
||||
proportionally identical garment on all 11 bodies. The parameter block below
|
||||
is the reusable seam for the rest of the hooded/long-sleeve family
|
||||
(track_jacket, sweater_modern): import this module and override.
|
||||
|
||||
Hoodie deltas over the base t-shirt shell:
|
||||
* covers torso + torso_upper + FULL arms (long sleeves, wrist-cut on the
|
||||
lowerarm bone instead of the upperarm short-sleeve cut)
|
||||
* HOOD DOWN — a rolled collar bulk ring: collar-band verts are inflated
|
||||
radially away from the neck axis (back-biased, slight upward lift) before
|
||||
solidify, so the roll gets real thickness and caps into a ring
|
||||
* looser standoff (16 mm vs the 12 mm per-body tee) + thicker cloth (6 mm)
|
||||
* painted albedo identity (texture-carried, per body because UV0 face
|
||||
classification is per body): kangaroo pocket fill + stitch outline,
|
||||
drawstrings, ribbed hem + cuff bands — all painted as LUMINANCE detail so
|
||||
the luma-preserving toon_garment recolor keeps them under any tint
|
||||
* region mask: R = collar/hood roll (asymmetric drop — drapes lower on the
|
||||
back), G = body, B = sleeves + kangaroo pocket
|
||||
* logo-capable chest via the base UV2 channel (unchanged)
|
||||
|
||||
PER-BODY ONLY (Q-060: offset-shells are authored per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_hoodie.py -- \
|
||||
client/assets/characters/bodies client/assets/characters/clothing/hoodie_modern \
|
||||
[--bodies a,b,c] [--offset M]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb, <body>_mask.png, <body>_base_albedo.png
|
||||
Plus fallbacks: <out_dir>/base_albedo.png + reference_mask.png (= average_m's).
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load_base():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load_base()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Hoodie parameters (the reusable seam — override for track_jacket/sweater)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
GARMENT_ID = "hoodie_modern"
|
||||
|
||||
SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
]
|
||||
|
||||
OFFSET_M = 0.016 # loose standoff (per-body construction guarantees it)
|
||||
THICKNESS_M = 0.006 # fleece-weight cloth
|
||||
WRIST_FRAC = 0.92 # fraction of the lowerarm kept (sleeve ends at wrist)
|
||||
|
||||
# Hood-down roll: fractions of neck_01 length unless noted.
|
||||
ROLL_OUT_FRAC = 0.40 # radial bulge magnitude
|
||||
ROLL_LIFT_FRAC = 0.14 # upward lift at the rim
|
||||
ROLL_Z_START_FRAC = 0.45 # roll influence starts this far below collar_z_min
|
||||
ROLL_REACH_COLLAR_X = 1.7 # candidate radius around the neck axis (x collar_x_abs)
|
||||
ROLL_FRONT_GAIN = 0.55 # bulge scale at the front...
|
||||
ROLL_BACK_GAIN = 1.10 # ...and at the back (hood mass hangs behind)
|
||||
ROLL_EXPONENT = 1.7 # falloff sharpness toward the rim
|
||||
|
||||
# Region mask R (roll) band: drop below collar_z_min, x neck_len, per side.
|
||||
R_Z_DROP_FRONT = 0.15
|
||||
R_Z_DROP_BACK = 0.55
|
||||
|
||||
# Kangaroo pocket: z as fractions of the waistband-top -> chest_z_lo span
|
||||
# (sits ABOVE the ribbed hem band), x as fractions of shoulder |x|.
|
||||
POCKET_Z0_FRAC = 0.06
|
||||
POCKET_Z1_FRAC = 0.88
|
||||
POCKET_WB_FRAC = 0.56 # bottom half-width
|
||||
POCKET_WT_FRAC = 0.30 # top half-width (diagonal hand openings)
|
||||
POCKET_FILL_MULT = 0.95 # subtle patch shading
|
||||
POCKET_LINE_MULT = 0.70 # stitch outline darkening
|
||||
POCKET_LINE_PX = 2 # outline thickness (erosion iterations)
|
||||
|
||||
# Drawstrings (front only): x offset / half-width as fractions of collar_x_abs,
|
||||
# z as fractions of neck_len.
|
||||
STRING_X_FRAC = 0.30
|
||||
STRING_HALF_W_M = 0.005
|
||||
STRING_TOP_DROP_FRAC = 0.10
|
||||
STRING_LEN_FRAC = 0.75
|
||||
STRING_MULT = 0.50
|
||||
|
||||
# Ribbed bands: hem (x spine span) and cuffs (x lowerarm length). The cuff is
|
||||
# anchored to the mesh's ACTUAL post-cut reach, not the nominal wrist plane —
|
||||
# the vert-threshold cut leaves a jagged face boundary that stops 1-3 cm short
|
||||
# of the plane, so a nominal-anchored band would mostly land on deleted faces.
|
||||
HEM_BAND_FRAC = 0.08
|
||||
CUFF_LEN_FRAC = 0.16
|
||||
BAND_MULT = 0.85
|
||||
BAND_LINE_MULT = 0.72
|
||||
BAND_LINE_M = 0.004
|
||||
|
||||
# Muted street-tone fabric (luma drives the toon_garment recolor).
|
||||
FABRIC_RGB = (0.55, 0.56, 0.58)
|
||||
FABRIC_NOISE = 0.035
|
||||
ALBEDO_SEED = 1091
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[hoodie] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Landmarks beyond the base thresholds
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def derive_landmarks(armature, thr):
|
||||
"""Hoodie-specific bone landmarks (all in body-local metres)."""
|
||||
bones = armature.data.bones
|
||||
neck = bones.get("neck_01")
|
||||
la_l = bones.get("lowerarm_l")
|
||||
la_r = bones.get("lowerarm_r")
|
||||
if not all([neck, la_l, la_r]):
|
||||
raise RuntimeError("landmark bones missing (neck_01 / lowerarm_l/r)")
|
||||
neck_len = neck.tail_local.z - neck.head_local.z
|
||||
lm = {
|
||||
"neck_y": neck.head_local.y,
|
||||
"neck_len": neck_len,
|
||||
"roll_out": ROLL_OUT_FRAC * neck_len,
|
||||
"roll_lift": ROLL_LIFT_FRAC * neck_len,
|
||||
"roll_reach": ROLL_REACH_COLLAR_X * thr["collar_x_abs"],
|
||||
# wrist cut thresholds per side: (sign, x threshold)
|
||||
"wrist_cuts": [],
|
||||
"lowerarm_len": 0.0,
|
||||
}
|
||||
for b, sign in [(la_l, +1), (la_r, -1)]:
|
||||
head_x, tail_x = b.head_local.x, b.tail_local.x
|
||||
thr_x = head_x + WRIST_FRAC * (tail_x - head_x)
|
||||
lm["wrist_cuts"].append((sign, thr_x))
|
||||
lm["lowerarm_len"] = abs(tail_x - head_x)
|
||||
log(f"landmarks: neck_len {neck_len:.4f} roll_out {lm['roll_out']*1000:.0f}mm "
|
||||
f"reach {lm['roll_reach']:.3f} wrist cuts "
|
||||
+ " ".join(f"{s:+d}@{t:.3f}" for s, t in lm["wrist_cuts"]))
|
||||
return lm
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: wrist cut + hood roll
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def wrist_cut(shell, lm):
|
||||
"""Trim the sleeve tubes at the wrist plane (same pattern as base.sleeve_cut,
|
||||
but on the lowerarm bone so the sleeves stay full length)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = []
|
||||
for v in bm.verts:
|
||||
for sign, thr_x in lm["wrist_cuts"]:
|
||||
if sign > 0 and v.co.x > thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
if sign < 0 and v.co.x < thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"wrist cut removed {len(to_delete)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def hood_roll(shell, thr, lm):
|
||||
"""Inflate collar-band verts radially away from the neck axis to read as a
|
||||
rolled-down hood: back-biased bulge + slight rim lift. Runs after the
|
||||
outward offset and before solidify (the roll then gets cloth thickness)."""
|
||||
z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * lm["neck_len"]
|
||||
reach = lm["roll_reach"]
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
|
||||
candidates = []
|
||||
z_rim = z_start
|
||||
for v in bm.verts:
|
||||
if v.co.z < z_start:
|
||||
continue
|
||||
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
|
||||
if hd > reach or hd < 1e-6:
|
||||
continue
|
||||
candidates.append((v, hd))
|
||||
z_rim = max(z_rim, v.co.z)
|
||||
if z_rim <= z_start or not candidates:
|
||||
log("WARNING: no hood-roll candidates found — roll skipped")
|
||||
bm.free()
|
||||
return
|
||||
|
||||
moved = 0
|
||||
for v, hd in candidates:
|
||||
t = (v.co.z - z_start) / (z_rim - z_start)
|
||||
w = max(0.0, min(1.0, t)) ** ROLL_EXPONENT
|
||||
if w <= 0.0:
|
||||
continue
|
||||
dir_h = Vector((v.co.x, v.co.y - lm["neck_y"], 0.0)) / hd
|
||||
backness = 0.5 * (1.0 + dir_h.y * -base.FRONT_Y_SIGN) # +Y = back
|
||||
gain = ROLL_FRONT_GAIN + (ROLL_BACK_GAIN - ROLL_FRONT_GAIN) * backness
|
||||
v.co += dir_h * (lm["roll_out"] * w * gain)
|
||||
v.co.z += lm["roll_lift"] * w
|
||||
moved += 1
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"hood roll: {moved} verts inflated (rim z {z_rim:.3f}, "
|
||||
f"start z {z_start:.3f})")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Paint maps: rasterize body-space (x, z) into UV0 texel space
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _raster_tri_attr(maps, a, b, c, xs, zs, is_front, is_back, W, H):
|
||||
"""Barycentric rasterization interpolating body-space x/z per texel."""
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, px_x = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
px = px_x + 0.5
|
||||
py = ys + 0.5
|
||||
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
|
||||
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
x_val = w0 * xs[0] + w1 * xs[1] + w2 * xs[2]
|
||||
z_val = w0 * zs[0] + w1 * zs[1] + w2 * zs[2]
|
||||
sl = (slice(miny, maxy + 1), slice(minx, maxx + 1))
|
||||
maps["X"][sl][inside] = x_val[inside]
|
||||
maps["Z"][sl][inside] = z_val[inside]
|
||||
maps["valid"][sl][inside] = True
|
||||
if is_front:
|
||||
maps["front"][sl][inside] = True
|
||||
if is_back:
|
||||
maps["back"][sl][inside] = True
|
||||
|
||||
|
||||
def bake_paint_maps(shell, W, H):
|
||||
"""Rasterize the shell's UV0 layout into per-texel body-space X/Z maps,
|
||||
with front/back facing flags (front = -Y on this rig)."""
|
||||
maps = {
|
||||
"X": np.zeros((H, W), dtype=np.float32),
|
||||
"Z": np.zeros((H, W), dtype=np.float32),
|
||||
"valid": np.zeros((H, W), dtype=bool),
|
||||
"front": np.zeros((H, W), dtype=bool),
|
||||
"back": np.zeros((H, W), dtype=bool),
|
||||
}
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.normal_update()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for paint-map bake")
|
||||
for face in bm.faces:
|
||||
n_front = face.normal.y * base.FRONT_Y_SIGN
|
||||
c_front = face.calc_center_median().y * base.FRONT_Y_SIGN
|
||||
is_front = n_front > 0.15 and c_front > 0.0
|
||||
is_back = n_front < -0.15 and c_front < 0.0
|
||||
loops = face.loops[:]
|
||||
uvs = [lp[uv_layer].uv.copy() for lp in loops]
|
||||
cos = [lp.vert.co for lp in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_attr(
|
||||
maps, uvs[0], uvs[i], uvs[i + 1],
|
||||
(cos[0].x, cos[i].x, cos[i + 1].x),
|
||||
(cos[0].z, cos[i].z, cos[i + 1].z),
|
||||
is_front, is_back, W, H)
|
||||
bm.free()
|
||||
overlap = int((maps["front"] & maps["back"]).sum())
|
||||
log(f"paint maps: {int(maps['valid'].sum())} texels "
|
||||
f"(front {int(maps['front'].sum())}, back {int(maps['back'].sum())}, "
|
||||
f"front/back UV overlap {overlap})")
|
||||
dump = os.environ.get("HOODIE_DEBUG_MAPS", "")
|
||||
if dump:
|
||||
np.savez_compressed(dump, **maps)
|
||||
log(f"debug maps dumped -> {dump}")
|
||||
return maps
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Painted albedo (texture-carried modern identity)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def derive_paint_params(shell, thr, lm):
|
||||
"""Body-space paint geometry, derived from thresholds + final shell mesh."""
|
||||
sleeve_x = thr["sleeve_x_abs"]
|
||||
hem_z = min((v.co.z for v in shell.data.vertices
|
||||
if abs(v.co.x) < sleeve_x), default=1.0)
|
||||
# Actual sleeve reach from the final mesh (jagged post-cut boundary).
|
||||
wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=sleeve_x)
|
||||
chest_lo = thr["chest_z"][0]
|
||||
hem_band = HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
|
||||
hem_top = hem_z + hem_band
|
||||
span = chest_lo - hem_top
|
||||
shoulder_x = thr["chest_x"][1] / base.CHEST_X_FRAC # invert base ratio
|
||||
p = {
|
||||
"hem_z": hem_z,
|
||||
"hem_band": hem_band,
|
||||
"pocket_z0": hem_top + POCKET_Z0_FRAC * span,
|
||||
"pocket_z1": hem_top + POCKET_Z1_FRAC * span,
|
||||
"pocket_wb": POCKET_WB_FRAC * shoulder_x,
|
||||
"pocket_wt": POCKET_WT_FRAC * shoulder_x,
|
||||
"string_x": STRING_X_FRAC * thr["collar_x_abs"],
|
||||
"string_z_top": thr["collar_z_min"] - STRING_TOP_DROP_FRAC * lm["neck_len"],
|
||||
"string_len": STRING_LEN_FRAC * lm["neck_len"],
|
||||
"cuff_len": CUFF_LEN_FRAC * lm["lowerarm_len"],
|
||||
"wrist_x": wrist_x,
|
||||
"sleeve_x": sleeve_x,
|
||||
}
|
||||
log(f"paint params: hem {hem_z:.3f} pocket z ({p['pocket_z0']:.3f},"
|
||||
f"{p['pocket_z1']:.3f}) w ({p['pocket_wt']:.3f}->{p['pocket_wb']:.3f})")
|
||||
return p
|
||||
|
||||
|
||||
def _erode(m, iters):
|
||||
for _ in range(iters):
|
||||
m = (m
|
||||
& np.roll(m, 1, 0) & np.roll(m, -1, 0)
|
||||
& np.roll(m, 1, 1) & np.roll(m, -1, 1))
|
||||
return m
|
||||
|
||||
|
||||
def pocket_mask(maps, p):
|
||||
"""Kangaroo-pocket texel mask: front-only trapezoid with diagonal sides."""
|
||||
X, Z = maps["X"], maps["Z"]
|
||||
paintable = maps["front"] & ~maps["back"]
|
||||
z0, z1 = p["pocket_z0"], p["pocket_z1"]
|
||||
if z1 <= z0:
|
||||
return np.zeros_like(paintable)
|
||||
t = np.clip((z1 - Z) / (z1 - z0), 0.0, 1.0) # 1 at bottom, 0 at top
|
||||
half_w = p["pocket_wt"] + (p["pocket_wb"] - p["pocket_wt"]) * t
|
||||
inside = paintable & (Z >= z0) & (Z <= z1) & (np.abs(X) <= half_w)
|
||||
log(f"pocket mask: {int(inside.sum())} texels")
|
||||
return inside
|
||||
|
||||
|
||||
def make_painted_albedo(maps, pocket_px, p, out_dir, body):
|
||||
"""Flat street-tone fabric + painted luminance detail; returns bpy image."""
|
||||
W = H = base.ALBEDO_SIZE
|
||||
rng = np.random.default_rng(ALBEDO_SEED)
|
||||
fabric = np.array(FABRIC_RGB, dtype=np.float32)
|
||||
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE
|
||||
rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
||||
|
||||
X, Z = maps["X"], maps["Z"]
|
||||
valid = maps["valid"]
|
||||
front_only = maps["front"] & ~maps["back"]
|
||||
|
||||
# Ribbed hem band (all around) + top stitch line.
|
||||
hem_top = p["hem_z"] + p["hem_band"]
|
||||
band = valid & (Z <= hem_top) & (np.abs(X) <= p["sleeve_x"])
|
||||
rgb[band] *= BAND_MULT
|
||||
line = valid & (np.abs(Z - hem_top) <= BAND_LINE_M) & (np.abs(X) <= p["sleeve_x"])
|
||||
rgb[line] *= BAND_LINE_MULT
|
||||
|
||||
# Ribbed cuffs (all around) + inner stitch line.
|
||||
cuff_x0 = p["wrist_x"] - p["cuff_len"]
|
||||
cuff = valid & (np.abs(X) >= cuff_x0)
|
||||
rgb[cuff] *= BAND_MULT
|
||||
cline = valid & (np.abs(np.abs(X) - cuff_x0) <= BAND_LINE_M)
|
||||
rgb[cline] *= BAND_LINE_MULT
|
||||
log(f"paint counts: hem {int(band.sum())} hemline {int(line.sum())} "
|
||||
f"cuff {int(cuff.sum())} cuffline {int(cline.sum())} "
|
||||
f"(hem_top {hem_top:.3f}, cuff_x0 {cuff_x0:.3f})")
|
||||
zs = Z[valid]
|
||||
xs_v = np.abs(X[valid])
|
||||
log(f"map ranges: Z [{zs.min():.3f},{zs.max():.3f}] "
|
||||
f"|X| [{xs_v.min():.3f},{xs_v.max():.3f}] "
|
||||
f"Z<=hem_top {int((zs <= hem_top).sum())} "
|
||||
f"|X|>=cuff_x0 {int((xs_v >= cuff_x0).sum())}")
|
||||
|
||||
# Kangaroo pocket: subtle fill + dark stitch outline.
|
||||
rgb[pocket_px] *= POCKET_FILL_MULT
|
||||
outline = pocket_px & ~_erode(pocket_px, POCKET_LINE_PX)
|
||||
rgb[outline] *= POCKET_LINE_MULT
|
||||
|
||||
# Drawstrings (front only, hanging from the collar).
|
||||
z_top = p["string_z_top"]
|
||||
z_bot = z_top - p["string_len"]
|
||||
for sx in (+p["string_x"], -p["string_x"]):
|
||||
s = front_only & (np.abs(X - sx) <= STRING_HALF_W_M) \
|
||||
& (Z >= z_bot) & (Z <= z_top)
|
||||
rgb[s] *= STRING_MULT
|
||||
|
||||
rgba = np.concatenate([rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
|
||||
img = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False)
|
||||
img.pixels.foreach_set(rgba.reshape(-1))
|
||||
img.update()
|
||||
sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
img.filepath_raw = sidecar
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
log(f"painted albedo -> {sidecar}")
|
||||
# The glTF exporter names the embedded image after the file basename, and
|
||||
# the Godot import EXTRACTS it as <glb>_<imagename>.png. Point the image at
|
||||
# the shared base_albedo.png so the extraction lands exactly on the sidecar
|
||||
# name above (<body>_base_albedo.png, same pixels — tshirt convention),
|
||||
# instead of a doubled <body>_<body>_base_albedo.png. The shared file is
|
||||
# re-pointed to the reference body's paint at the end of the run.
|
||||
img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
|
||||
img.save()
|
||||
return img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Region mask: R = hood roll, G = body, B = sleeves + pocket
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _classify_hoodie(center, thr, lm):
|
||||
x, y, z = center.x, center.y, center.z
|
||||
if abs(x) >= thr["sleeve_x_abs"]:
|
||||
return (0.0, 0.0, 1.0, 0.0) # sleeves -> B
|
||||
hd = math.hypot(x, y - lm["neck_y"])
|
||||
is_front = y * base.FRONT_Y_SIGN > 0.0
|
||||
drop = R_Z_DROP_FRONT if is_front else R_Z_DROP_BACK
|
||||
z_min = thr["collar_z_min"] - drop * lm["neck_len"]
|
||||
if z >= z_min and hd <= lm["roll_reach"] + lm["roll_out"] + 0.01:
|
||||
return (1.0, 0.0, 0.0, 0.0) # hood roll -> R
|
||||
return (0.0, 1.0, 0.0, 0.0) # body -> G
|
||||
|
||||
|
||||
def bake_hoodie_mask(shell, out_path, thr, lm, pocket_px):
|
||||
"""Per-face region rasterization (like base.bake_region_mask) with the
|
||||
hoodie classifier, then the pocket texels overlaid into B."""
|
||||
W = H = base.MASK_SIZE
|
||||
buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
buf[:, :, 1] = 1.0 # body-green background (bleed safety)
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.faces.ensure_lookup_table()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for region mask bake")
|
||||
counts = {"roll": 0, "body": 0, "sleeve": 0}
|
||||
for face in bm.faces:
|
||||
color = _classify_hoodie(face.calc_center_median(), thr, lm)
|
||||
if color[0] > 0.5:
|
||||
counts["roll"] += 1
|
||||
elif color[2] > 0.5:
|
||||
counts["sleeve"] += 1
|
||||
else:
|
||||
counts["body"] += 1
|
||||
for a, b, c in base._tris_from_face(face, uv_layer):
|
||||
base._raster_tri(buf, a, b, c, color, W, H)
|
||||
bm.free()
|
||||
total = max(sum(counts.values()), 1)
|
||||
log("region faces: " + " ".join(
|
||||
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
|
||||
|
||||
# Pocket joins the sleeve tint region (B), per the spec.
|
||||
if pocket_px is not None and pocket_px.shape == (H, W):
|
||||
buf[pocket_px] = (0.0, 0.0, 1.0, 0.0)
|
||||
|
||||
img = bpy.data.images.new(f"{GARMENT_ID}_mask", W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = out_path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
log(f"baked region mask -> {out_path}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_hoodie(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
thr = base.derive_thresholds(armature)
|
||||
lm = derive_landmarks(armature, thr)
|
||||
|
||||
wrist_cut(shell, lm)
|
||||
base.offset_outward(shell, offset)
|
||||
hood_roll(shell, thr, lm)
|
||||
base.solidify(shell, THICKNESS_M)
|
||||
|
||||
maps = bake_paint_maps(shell, base.ALBEDO_SIZE, base.ALBEDO_SIZE)
|
||||
p = derive_paint_params(shell, thr, lm)
|
||||
pocket_px = pocket_mask(maps, p)
|
||||
|
||||
albedo_img = make_painted_albedo(maps, pocket_px, p, out_dir, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
base.author_logo_uv(shell, thr)
|
||||
|
||||
# Mask texels are MASK_SIZE; paint maps are ALBEDO_SIZE. Sizes match (512)
|
||||
# today; rebake the pocket mask if they ever diverge.
|
||||
mask_pocket = pocket_px if base.MASK_SIZE == base.ALBEDO_SIZE else None
|
||||
bake_hoodie_mask(shell, os.path.join(out_dir, f"{body}_mask.png"),
|
||||
thr, lm, mask_pocket)
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] [--offset M]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
base.COVERED_SEGMENTS = SEGMENTS
|
||||
|
||||
log(f"per-body hoodie: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_hoodie(body_dir, out_dir, body, offset)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Runtime fallbacks mirror the reference body (average_m).
|
||||
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log("copied reference_mask.png fallback")
|
||||
ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
|
||||
if os.path.isfile(ref_albedo):
|
||||
shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
|
||||
log("copied base_albedo.png fallback")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
blender_author_jacket_shell.py (T-1089, route (c) hand-author proof: suit jacket)
|
||||
|
||||
Jacket-family companion to blender_author_offset_shell.py — imports that module
|
||||
as a library (segments -> join -> offset -> solidify -> skinned export are
|
||||
reused unchanged) and adds what a JACKET needs beyond a t-shirt:
|
||||
|
||||
* full-arm coverage (torso + torso_upper + upper AND lower arms) with a
|
||||
WRIST cut on the lowerarm bone (long sleeves) instead of an upper-arm cut;
|
||||
* a boundary WELD after the segment join, so the elbow/shoulder segment
|
||||
seams offset as one continuous surface (no gap rings on long sleeves);
|
||||
* a jacket standoff (default 18 mm — outerwear drape over the 12 mm tee);
|
||||
* a PAINTED albedo + region mask baked together in ONE per-pixel
|
||||
rasterization pass: each texel's 3D body-local position is interpolated
|
||||
barycentric-ally, so the V-opening / lapels / shirt triangle have crisp
|
||||
edges independent of the low-poly tessellation (the base script's
|
||||
per-FACE classification would read chunky on a chest V).
|
||||
|
||||
Region-mask convention for the suit (toon_garment.gdshader tint routing):
|
||||
R = lapels + collar band (tint_0 — subtle satin two-tone)
|
||||
G = jacket body (tint_1)
|
||||
B = sleeves (tint_2)
|
||||
A = shirt triangle in the V (tint_3 — default WHITE so outfits can
|
||||
recolor the visible shirt)
|
||||
|
||||
Painted albedo details (luma-carried; the shader recolors hue per region but
|
||||
keeps albedo luminance, so detail must live in the 0.53-max luma band or the
|
||||
`luma*1.5+0.2` curve clips it): white-shirt triangle with placket + buttons,
|
||||
lapel fold + edge shading, front closure seam + jacket button below the V,
|
||||
sleeve cuff band. All geometry parameters are bone-landmark RATIOS (same
|
||||
anchors as the base script: shoulder = upperarm head |x|, neck_01, spine_01,
|
||||
plus lowerarm for the wrist), so every body derives its own proportional
|
||||
cut/paint constants — calibrated to the intended metric sizes on average_m.
|
||||
|
||||
PER-BODY ONLY: offset-shell garments are authored per body (Q-060 route
|
||||
guidance) — there is no single-reference mode here.
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_jacket_shell.py -- \
|
||||
<bodies_root> <out_dir> \
|
||||
[--bodies average_m,child,...] [--offset 0.018] [--wrist-frac 0.85]
|
||||
|
||||
Writes per body:
|
||||
<out_dir>/<body>.glb skinned jacket authored on that body
|
||||
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0)
|
||||
Plus:
|
||||
<out_dir>/base_albedo.png copy of average_m's painted albedo
|
||||
<out_dir>/reference_mask.png copy of average_m's mask (runtime fallback)
|
||||
|
||||
The painted per-body albedo is embedded in each GLB (image URI basename
|
||||
"base_albedo", staged under .cache/garment-fit-suit/<body>/); on import
|
||||
Godot extracts it to <out_dir>/<body>_base_albedo.png — the same layout the
|
||||
t-shirt reference garment has.
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
|
||||
# Import the base offset-shell module as a library.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import blender_author_offset_shell as base # noqa: E402
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Jacket parameters
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
JACKET_SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
]
|
||||
|
||||
JACKET_OFFSET_M = 0.018 # outerwear standoff (tee is 12 mm per-body)
|
||||
WRIST_FRAC = 0.92 # fraction of lowerarm length kept. NB the vert-delete
|
||||
# cut also drops faces touching deleted verts, so the
|
||||
# visible sleeve ends ~1 low-poly ring earlier — 0.92
|
||||
# lands the hem just above the wrist (matches the
|
||||
# buttondown's calibrated SLEEVE_END_FRAC).
|
||||
CUFF_FRAC = 0.14 # last fraction of the KEPT sleeve painted as cuff band
|
||||
WELD_DIST_M = 0.0001 # merge distance for segment-boundary weld
|
||||
|
||||
BAKE_SIZE = 1024 # albedo + mask resolution (512 is too coarse for the
|
||||
# 6 mm placket / button dots on the torso island)
|
||||
|
||||
# --- V-opening / lapel geometry, as ratios of average_m bone landmarks -----
|
||||
# (anchors identical to base: shoulder |x| 0.1919, neck head z 1.5205,
|
||||
# spine_01 head z 1.072 -> spine span 0.4485)
|
||||
V_HALF_TOP_M = 0.068 # V half-width at the collar on average_m
|
||||
V_BOT_Z_M = 1.17 # V bottom (jacket closure point) on average_m
|
||||
LAPEL_W_M = 0.045 # lapel band width
|
||||
LAPEL_DROP_M = 0.020 # lapel wraps slightly below the V point
|
||||
EDGE_W_M = 0.011 # lapel fold/edge shading line width (albedo only)
|
||||
PLACKET_HALF_M = 0.006 # shirt placket half-width
|
||||
CLOSURE_HALF_M = 0.0045 # jacket front closure seam half-width
|
||||
SHIRT_BTN_R_M = 0.0055 # shirt button radius
|
||||
JACKET_BTN_R_M = 0.009 # jacket button radius
|
||||
JACKET_BTN_DROP_M = 0.030 # jacket button sits this far below the V point
|
||||
SHIRT_BTN_FRACS = (0.11, 0.30) # shirt button z, as frac of (v_top - v_bot)
|
||||
|
||||
V_HALF_TOP_FRAC = V_HALF_TOP_M / base._REF_SHOULDER_X
|
||||
LAPEL_W_FRAC = LAPEL_W_M / base._REF_SHOULDER_X
|
||||
EDGE_W_FRAC = EDGE_W_M / base._REF_SHOULDER_X
|
||||
PLACKET_HALF_FRAC = PLACKET_HALF_M / base._REF_SHOULDER_X
|
||||
CLOSURE_HALF_FRAC = CLOSURE_HALF_M / base._REF_SHOULDER_X
|
||||
SHIRT_BTN_R_FRAC = SHIRT_BTN_R_M / base._REF_SHOULDER_X
|
||||
JACKET_BTN_R_FRAC = JACKET_BTN_R_M / base._REF_SHOULDER_X
|
||||
_REF_SPAN = base._REF_NECK_Z - base._REF_SPINE_LO_Z
|
||||
V_BOT_FRAC = (V_BOT_Z_M - base._REF_SPINE_LO_Z) / _REF_SPAN
|
||||
LAPEL_DROP_FRAC = LAPEL_DROP_M / _REF_SPAN
|
||||
JACKET_BTN_DROP_FRAC = JACKET_BTN_DROP_M / _REF_SPAN
|
||||
|
||||
# --- Painted albedo luma palette (neutral hue; shader tints supply color) ---
|
||||
# Kept inside the luma*1.5+0.2 <= 1.0 budget (luma <= 0.53) so painted detail
|
||||
# survives a white tint on the shirt region.
|
||||
LUMA_FABRIC = 0.400 # jacket body + sleeves + back
|
||||
LUMA_SATIN = 0.475 # lapels + collar band (subtle two-tone vs body)
|
||||
LUMA_EDGE = 0.250 # lapel fold/edge shading lines
|
||||
LUMA_SHIRT = 0.520 # shirt triangle (renders ~white under white tint)
|
||||
LUMA_PLACKET = 0.440 # shirt placket strip
|
||||
LUMA_SHIRT_BTN = 0.320 # shirt buttons
|
||||
LUMA_CLOSURE = 0.290 # jacket closure seam below the V
|
||||
LUMA_JACKET_BTN = 0.180 # jacket button
|
||||
LUMA_CUFF = 0.330 # sleeve cuff band
|
||||
ALBEDO_NOISE = 0.018 # +/- woven jitter (4-px blocks — compresses well)
|
||||
ALBEDO_TINT = (1.0, 1.0, 1.03) # slightly cool cast on the neutral greys
|
||||
|
||||
log = base.log
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Suit-specific threshold derivation (extends base.derive_thresholds)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def derive_jacket_thresholds(armature, wrist_frac):
|
||||
thr = base.derive_thresholds(armature)
|
||||
bones = armature.data.bones
|
||||
ua_l = bones.get("upperarm_l")
|
||||
neck = bones.get("neck_01")
|
||||
spine01 = bones.get("spine_01")
|
||||
|
||||
if ua_l and neck and spine01:
|
||||
ua_r = bones.get("upperarm_r")
|
||||
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
spine_lo = spine01.head_local.z
|
||||
span = neck.head_local.z - spine_lo
|
||||
else:
|
||||
log("WARNING: landmark bones missing — legacy average_m jacket constants")
|
||||
shoulder_x = base._REF_SHOULDER_X
|
||||
spine_lo = base._REF_SPINE_LO_Z
|
||||
span = _REF_SPAN
|
||||
|
||||
v_top = thr["collar_z_min"]
|
||||
v_bot = spine_lo + V_BOT_FRAC * span
|
||||
thr.update({
|
||||
"v_top_z": v_top,
|
||||
"v_bot_z": v_bot,
|
||||
"v_half_top": shoulder_x * V_HALF_TOP_FRAC,
|
||||
"lapel_w": shoulder_x * LAPEL_W_FRAC,
|
||||
"lapel_drop": LAPEL_DROP_FRAC * span,
|
||||
"edge_w": shoulder_x * EDGE_W_FRAC,
|
||||
"placket_half": shoulder_x * PLACKET_HALF_FRAC,
|
||||
"closure_half": shoulder_x * CLOSURE_HALF_FRAC,
|
||||
"shirt_btn_r": shoulder_x * SHIRT_BTN_R_FRAC,
|
||||
"jacket_btn_r": shoulder_x * JACKET_BTN_R_FRAC,
|
||||
"jacket_btn_z": v_bot - JACKET_BTN_DROP_FRAC * span,
|
||||
"shirt_btn_zs": [v_bot + f * (v_top - v_bot) for f in SHIRT_BTN_FRACS],
|
||||
})
|
||||
|
||||
# Wrist cut planes + cuff band start, from the lowerarm bones (arms run
|
||||
# along +/-X in rest pose, elbow head -> wrist tail).
|
||||
cut_planes = [] # (sign, wrist_thr_x, cuff_start_x)
|
||||
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
|
||||
b = bones.get(bone_name)
|
||||
if b is None:
|
||||
log(f"WARNING: bone {bone_name} missing — sleeve uncut on that side")
|
||||
continue
|
||||
head_x = b.head_local.x
|
||||
tail_x = b.tail_local.x
|
||||
wrist_thr = head_x + wrist_frac * (tail_x - head_x)
|
||||
cuff_start = head_x + wrist_frac * (1.0 - CUFF_FRAC) * (tail_x - head_x)
|
||||
cut_planes.append((sign, wrist_thr, cuff_start))
|
||||
log(f"wrist cut {bone_name}: keep |x| to {wrist_thr:.3f} "
|
||||
f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {cuff_start:.3f}")
|
||||
thr["wrist_planes"] = cut_planes
|
||||
|
||||
log(f"jacket thresholds: V top z {v_top:.3f} bottom z {v_bot:.3f} "
|
||||
f"half-top {thr['v_half_top']:.3f} lapel {thr['lapel_w']:.3f} "
|
||||
f"btn z {thr['jacket_btn_z']:.3f}")
|
||||
return thr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: weld + wrist cut
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_segment_boundaries(shell):
|
||||
"""Merge coincident verts along segment seams (elbow/shoulder/chest lines).
|
||||
|
||||
Adjacent body segments duplicate their shared boundary loop; unwelded, the
|
||||
two copies get island-local normals and offset APART, opening a gap ring at
|
||||
every seam. Welding first makes the shell offset as one surface. Merged
|
||||
verts come from the same original body vertex, so weights and loop UVs are
|
||||
identical/preserved.
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST_M)
|
||||
bm.to_mesh(shell.data)
|
||||
after = len(shell.data.vertices)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"welded segment boundaries: {before} -> {after} verts "
|
||||
f"({before - after} merged)")
|
||||
|
||||
|
||||
def wrist_cut(shell, thr):
|
||||
"""Delete sleeve verts beyond the wrist plane on each lower arm."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = []
|
||||
for v in bm.verts:
|
||||
for sign, wrist_thr, _cuff in thr["wrist_planes"]:
|
||||
if sign > 0 and v.co.x > wrist_thr:
|
||||
to_delete.append(v)
|
||||
break
|
||||
if sign < 0 and v.co.x < wrist_thr:
|
||||
to_delete.append(v)
|
||||
break
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"wrist cut removed {len(to_delete)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Combined per-pixel albedo paint + region mask bake
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _classify_pixels(X, Y, Z, face_sleeve, face_side, thr):
|
||||
"""Vectorized region + luma classification for one triangle's texels.
|
||||
|
||||
Returns (mask_rgba[N,4], luma[N]) for interpolated body-local positions.
|
||||
Region priority: collar > shirt > lapel > body; sleeves are per-face.
|
||||
"""
|
||||
n = X.shape[0]
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
luma = np.full(n, LUMA_FABRIC, dtype=np.float32)
|
||||
|
||||
front = (Y * base.FRONT_Y_SIGN) > 0.010
|
||||
ax = np.abs(X)
|
||||
|
||||
if face_sleeve:
|
||||
mask[:, 2] = 1.0 # B = sleeves
|
||||
# cuff band on this arm's outer end
|
||||
for sign, wrist_thr, cuff_start in thr["wrist_planes"]:
|
||||
if sign != face_side:
|
||||
continue
|
||||
in_cuff = (X * sign) >= (cuff_start * sign)
|
||||
luma[in_cuff] = LUMA_CUFF
|
||||
return mask, luma
|
||||
|
||||
v_top = thr["v_top_z"]
|
||||
v_bot = thr["v_bot_z"]
|
||||
# V half-width narrows linearly from v_half_top at the collar to 0 at v_bot.
|
||||
t = np.clip((Z - v_bot) / max(v_top - v_bot, 1e-6), 0.0, None)
|
||||
vhalf = thr["v_half_top"] * t
|
||||
|
||||
collar = (Z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"])
|
||||
shirt = front & ~collar & (Z >= v_bot) & (Z < v_top) & (ax < vhalf)
|
||||
lapel_out = vhalf + thr["lapel_w"]
|
||||
lapel = (front & ~collar & ~shirt
|
||||
& (Z >= v_bot - thr["lapel_drop"]) & (Z < v_top)
|
||||
& (ax >= vhalf) & (ax < lapel_out))
|
||||
|
||||
mask[:, 0][collar | lapel] = 1.0 # R = lapels + collar band
|
||||
mask[:, 3][shirt] = 1.0 # A = shirt triangle
|
||||
body = ~(collar | shirt | lapel)
|
||||
mask[:, 1][body] = 1.0 # G = jacket body
|
||||
|
||||
# ---- painted luma detail ----
|
||||
luma[collar | lapel] = LUMA_SATIN
|
||||
# lapel fold (inner) + edge (outer) shading lines
|
||||
edge = lapel & ((ax < vhalf + thr["edge_w"]) | (ax > lapel_out - thr["edge_w"]))
|
||||
luma[edge] = LUMA_EDGE
|
||||
# shirt triangle: base, placket, buttons
|
||||
luma[shirt] = LUMA_SHIRT
|
||||
luma[shirt & (ax < thr["placket_half"])] = LUMA_PLACKET
|
||||
for bz in thr["shirt_btn_zs"]:
|
||||
btn = shirt & ((X ** 2 + (Z - bz) ** 2) < thr["shirt_btn_r"] ** 2)
|
||||
luma[btn] = LUMA_SHIRT_BTN
|
||||
# jacket closure seam + button below the V point
|
||||
closure = body & front & (Z < v_bot) & (ax < thr["closure_half"])
|
||||
luma[closure] = LUMA_CLOSURE
|
||||
jbtn = front & ((X ** 2 + (Z - thr["jacket_btn_z"]) ** 2)
|
||||
< thr["jacket_btn_r"] ** 2)
|
||||
luma[jbtn] = LUMA_JACKET_BTN
|
||||
return mask, luma
|
||||
|
||||
|
||||
def bake_albedo_and_mask(shell, thr, albedo_path, mask_path, albedo_name):
|
||||
"""One rasterization pass -> painted albedo PNG + RGBA region mask PNG.
|
||||
|
||||
Per texel the 3D body-local position is barycentric-interpolated, so paint
|
||||
and regions are classified per PIXEL (crisp V edges on coarse topology).
|
||||
Mask background = body green (bilinear bleed stays tinted); albedo
|
||||
background = fabric luma.
|
||||
"""
|
||||
W = H = BAKE_SIZE
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0
|
||||
albedo_buf = np.full((H, W), LUMA_FABRIC, dtype=np.float32)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for bake")
|
||||
|
||||
counts = {"collar_lapel": 0, "body": 0, "sleeve": 0, "shirt": 0}
|
||||
for face in bm.faces:
|
||||
center = face.calc_center_median()
|
||||
face_sleeve = abs(center.x) >= thr["sleeve_x_abs"]
|
||||
face_side = 1 if center.x >= 0.0 else -1
|
||||
loops = face.loops[:]
|
||||
uvs = [lo[uv_layer].uv for lo in loops]
|
||||
cos = [lo.vert.co for lo in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_pos(
|
||||
mask_buf, albedo_buf,
|
||||
uvs[0], uvs[i], uvs[i + 1],
|
||||
cos[0], cos[i], cos[i + 1],
|
||||
face_sleeve, face_side, thr, W, H, counts)
|
||||
bm.free()
|
||||
log("bake texel classes: " + " ".join(f"{k}={v}" for k, v in counts.items()))
|
||||
|
||||
# ---- save mask ----
|
||||
img = bpy.data.images.new("jacket_region_mask", W, H, alpha=True)
|
||||
img.pixels.foreach_set(mask_buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = mask_path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
|
||||
# ---- albedo: neutral grey luma + blocky woven noise, slightly cool ----
|
||||
rng = np.random.default_rng(1089)
|
||||
coarse = (rng.random((H // 4, W // 4), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
|
||||
noise = np.kron(coarse, np.ones((4, 4), dtype=np.float32))
|
||||
lum = np.clip(albedo_buf + noise, 0.0, 1.0)
|
||||
rgba = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c, mul in enumerate(ALBEDO_TINT):
|
||||
rgba[:, :, c] = np.clip(lum * mul, 0.0, 1.0)
|
||||
rgba[:, :, 3] = 1.0
|
||||
aimg = bpy.data.images.new(albedo_name, W, H, alpha=False)
|
||||
aimg.pixels.foreach_set(rgba.reshape(-1))
|
||||
aimg.update()
|
||||
aimg.filepath_raw = albedo_path
|
||||
aimg.file_format = 'PNG'
|
||||
aimg.save()
|
||||
log(f"painted albedo -> {albedo_path}")
|
||||
return aimg
|
||||
|
||||
|
||||
def _raster_tri_pos(mask_buf, albedo_buf, a, b, c, pa, pb, pc,
|
||||
face_sleeve, face_side, thr, W, H, counts):
|
||||
"""Barycentric fill interpolating 3D position for per-pixel classification."""
|
||||
ax_, ay_ = a.x * (W - 1), a.y * (H - 1)
|
||||
bx_, by_ = b.x * (W - 1), b.y * (H - 1)
|
||||
cx_, cy_ = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax_, bx_, cx_))), 0)
|
||||
maxx = min(int(np.ceil(max(ax_, bx_, cx_))), W - 1)
|
||||
miny = max(int(np.floor(min(ay_, by_, cy_))), 0)
|
||||
maxy = min(int(np.ceil(max(ay_, by_, cy_))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by_ - cy_) * (ax_ - cx_) + (cx_ - bx_) * (ay_ - cy_)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
px = xs + 0.5
|
||||
py = ys + 0.5
|
||||
w0 = ((by_ - cy_) * (px - cx_) + (cx_ - bx_) * (py - cy_)) / denom
|
||||
w1 = ((cy_ - ay_) * (px - cx_) + (ax_ - cx_) * (py - cy_)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
X = w0i * pa.x + w1i * pb.x + w2i * pc.x
|
||||
Y = w0i * pa.y + w1i * pb.y + w2i * pc.y
|
||||
Z = w0i * pa.z + w1i * pb.z + w2i * pc.z
|
||||
mask_px, luma_px = _classify_pixels(
|
||||
X.astype(np.float32), Y.astype(np.float32), Z.astype(np.float32),
|
||||
face_sleeve, face_side, thr)
|
||||
|
||||
counts["collar_lapel"] += int((mask_px[:, 0] > 0.5).sum())
|
||||
counts["shirt"] += int((mask_px[:, 3] > 0.5).sum())
|
||||
counts["sleeve"] += int((mask_px[:, 2] > 0.5).sum())
|
||||
counts["body"] += int((mask_px[:, 1] > 0.5).sum())
|
||||
|
||||
m_region = mask_buf[miny:maxy + 1, minx:maxx + 1, :]
|
||||
a_region = albedo_buf[miny:maxy + 1, minx:maxx + 1]
|
||||
m_region[inside] = mask_px
|
||||
a_region[inside] = luma_px
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parked UV2 (suit is not logo-capable; keep mesh format pipeline-consistent)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_parked_uv2(shell):
|
||||
me = shell.data
|
||||
while len(me.uv_layers) > 1:
|
||||
me.uv_layers.remove(me.uv_layers[-1])
|
||||
me.uv_layers.new(name="logo_uv")
|
||||
me.uv_layers.active = me.uv_layers[0]
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
uvl = bm.loops.layers.uv.get("logo_uv")
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
loop[uvl].uv = (2.0, 2.0)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log("UV2 authored fully parked (non-logo garment)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = JACKET_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
thr = derive_jacket_thresholds(armature, wrist_frac)
|
||||
weld_segment_boundaries(shell)
|
||||
wrist_cut(shell, thr)
|
||||
base.offset_outward(shell, offset)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
|
||||
# Stage the painted albedo OUTSIDE the Godot project as "base_albedo.png"
|
||||
# — the glTF image URI takes the file basename, so Godot's on-import
|
||||
# extraction lands at <out_dir>/<body>_base_albedo.png (tshirt layout)
|
||||
# without a duplicate authored sidecar next to it.
|
||||
body_stage = os.path.join(stage_dir, body)
|
||||
os.makedirs(body_stage, exist_ok=True)
|
||||
albedo_img = bake_albedo_and_mask(
|
||||
shell, thr,
|
||||
os.path.join(body_stage, "base_albedo.png"),
|
||||
os.path.join(out_dir, f"{body}_mask.png"),
|
||||
f"jacket_albedo_{body}")
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
author_parked_uv2(shell)
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> "
|
||||
"[--bodies a,b,c] [--offset M] [--wrist-frac F]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = JACKET_OFFSET_M
|
||||
wrist_frac = WRIST_FRAC
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--wrist-frac" in argv:
|
||||
wrist_frac = float(argv[argv.index("--wrist-frac") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
stage_dir = os.path.join(repo_root, ".cache", "garment-fit-suit")
|
||||
log(f"jacket per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
|
||||
f"wrist frac {wrist_frac}")
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(stage_dir, ref, "base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied staged {ref} albedo -> base_albedo.png")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
blender_author_joggers.py (T-1089 wave 2, joggers_modern + sweatpant family)
|
||||
|
||||
Authors JOGGERS/sweatpants as per-body offset shells: hips + full legs,
|
||||
tapered toward the ankle into snug cuff bands, painted waist drawstring, and
|
||||
a track SIDE STRIPE carried as its own tint region. Reuses
|
||||
blender_author_offset_shell.py (via blender_author_denim_pants.py's module
|
||||
instance) for scene build / join / solidify / export, and reuses the denim
|
||||
companion's REQUIRED lower-body practices directly (not re-derived):
|
||||
|
||||
* boundary WELD of coincident segment-seam rings before offsetting
|
||||
(denim.weld_boundaries) — un-welded rings offset apart along diverging
|
||||
normals and open cracks at the waist/knee joins.
|
||||
* open-rim FLATTENING (denim.flatten_open_rims) — the segment splitter
|
||||
leaves 4.5-7.6 cm jagged teeth at the waist/ankle rims; boundary verts are
|
||||
pulled onto clean planes pre-offset. The flattened ankle plane IS the
|
||||
jogger cuff hem.
|
||||
* waist flare + post-solidify residue clamp (denim.waist_flare,
|
||||
denim.clamp_waist_residue) — deep-crouch waist-fold clip mitigation.
|
||||
* per-body leg-axis landmarks (denim.LegLandmarks) — thigh_l/calf_l control
|
||||
points drive azimuth math for the side stripe and the cuff/band rib paint,
|
||||
so every parameter derives from the body's own bones (Q-060 per-body mode).
|
||||
|
||||
What this companion adds, as reusable parameters (not hacks):
|
||||
|
||||
* TAPERED variable offset — constant baggy standoff (--offset) above the
|
||||
knee, smoothstep taper to --mid-offset at the cuff top, then a short
|
||||
feathered step down to the snug --cuff-offset inside the cuff band: the
|
||||
sweatpant silhouette (baggy thigh -> tapered shin -> gripping cuff).
|
||||
* ankle CUFF band (--cuff-frac of the garment span) — its own mask region
|
||||
(A channel) with painted knit RIB (azimuth-arc alternating shades, constant
|
||||
metric rib width via the per-z leg axis).
|
||||
* SIDE STRIPE (--stripe-width metres, scaled by hip width) — constant-width
|
||||
azimuth band along the outer leg from waistband to cuff, painted bright
|
||||
AND masked to its own region (B channel) for independent tinting.
|
||||
* painted elastic waistband (subtle vertical rib) + centre-front DRAWSTRING:
|
||||
two eyelets and two hanging cords with a slight outward slant (albedo
|
||||
only; dark cords stay legible under the luma-scaled region tint).
|
||||
|
||||
Regions (RGBA mask, toon_garment.gdshader): waistband -> R (tint_0),
|
||||
legs -> G (tint_1), side stripe -> B (tint_2), ankle cuffs -> A (tint_3).
|
||||
A parked logo_uv TEXCOORD_1 layer is authored (denim.author_parked_uv2) —
|
||||
not logo-capable, but the shader samples UV2 unconditionally.
|
||||
|
||||
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r. Natural
|
||||
boundaries give the waist opening and ankle hems for free. Per-body mode only
|
||||
(offset shells author per body, Q-060).
|
||||
|
||||
Usage (joggers_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_joggers.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/joggers_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.014] [--mid-offset 0.010] \
|
||||
[--cuff-offset 0.0065] [--band-frac 0.055] [--cuff-frac 0.075] \
|
||||
[--stripe-width 0.034] [--fabric 0.55,0.56,0.58] \
|
||||
[--stripe-rgb 0.88,0.89,0.91] [--waist-flare 0.007] [--seed N] [--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the denim companion as a module — it carries the shared lower-body
|
||||
# practices (weld, rim flatten, waist flare, clamp, landmarks, parked UV2) and
|
||||
# its own instance of the base offset-shell library.
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"denim_pants", os.path.join(_HERE, "blender_author_denim_pants.py"))
|
||||
denim = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(denim)
|
||||
|
||||
base = denim.base # single shared base-module instance
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (defaults = joggers_modern)
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_hips",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
]
|
||||
|
||||
TEX_SIZE = 1024 # albedo + mask resolution (rib + cords need >512)
|
||||
WAIST_FLARE_M = 0.007 # denim-proven deep-crouch waist-fold mitigation
|
||||
|
||||
# Tapered-offset profile (metres).
|
||||
OFFSET_HI_M = 0.014 # hips/thigh standoff — baggy sweatpant volume
|
||||
OFFSET_MID_M = 0.010 # taper target at the cuff top (shin)
|
||||
OFFSET_CUFF_M = 0.0065 # snug cuff standoff (ankles barely deform)
|
||||
CUFF_STEP_W_M = 0.010 # feather width of the taper->cuff step
|
||||
|
||||
# Vertical proportions — fractions of the garment span (waist_z - hem_z).
|
||||
BAND_FRAC = 0.055 # elastic waistband height (R region)
|
||||
CUFF_FRAC = 0.075 # knit cuff band height (A region) — taller than a hem
|
||||
SEAM_W_FRAC = 0.008 # painted border-stitch line width
|
||||
CORD_DROP_FRAC = 0.050 # drawstring cord length below the waistband
|
||||
CORD_SLANT = 0.18 # outward cord slant (dx per unit hang depth)
|
||||
|
||||
# Horizontal / arc-metric proportions (scaled by hip half-width ratio lm.sh).
|
||||
STRIPE_W_M = 0.034 # side stripe metric width (B region)
|
||||
RIB_PERIOD_M = 0.009 # cuff knit-rib period (arc metres)
|
||||
BAND_RIB_PERIOD_M = 0.006 # waistband elastic-rib period (arc metres)
|
||||
CORD_X_M = 0.016 # drawstring eyelet/cord |x| at the band
|
||||
CORD_W_M = 0.005 # cord width
|
||||
EYELET_R_M = 0.0045 # eyelet dot radius
|
||||
|
||||
# Fleece style (sRGB floats; flat toon-friendly, texture carries identity).
|
||||
FLEECE_RGB = (0.55, 0.56, 0.58) # heather grey, luma ~0.56 (tint-faithful)
|
||||
STRIPE_RGB = (0.88, 0.89, 0.91) # near-white track stripe
|
||||
CORD_RGB = (0.17, 0.17, 0.19) # charcoal drawstring
|
||||
ALBEDO_NOISE = 0.020 # +/- fleece jitter
|
||||
BAND_SHADE = 0.94 # waistband overall darkening
|
||||
CUFF_SHADE = 0.96 # cuff overall darkening
|
||||
RIB_LO = 0.86 # cuff rib dark-stripe factor
|
||||
BAND_RIB_LO = 0.94 # waistband rib dark-stripe factor
|
||||
STITCH_SHADE = 0.80 # band/cuff border-stitch darkening
|
||||
PLAIN = False # --plain: skip drawstring/rib/stitch paint
|
||||
NOISE_SEED = 2090 # distinct from denim (2089)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Joggers landmark finalization (denim.LegLandmarks carries the leg axis; the
|
||||
# band/cuff fractions here are jogger parameters, not denim's)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def finalize_jogger_landmarks(lm, waist_plane, ankle_plane,
|
||||
band_frac, cuff_frac):
|
||||
lm.waist_z = waist_plane
|
||||
lm.hem_z = ankle_plane
|
||||
lm.span = waist_plane - ankle_plane
|
||||
lm.sh = lm.hip_x / denim._REF_HIP_X
|
||||
lm.band_h = band_frac * lm.span
|
||||
lm.cuff_h = cuff_frac * lm.span
|
||||
lm.seam_w = SEAM_W_FRAC * lm.span
|
||||
lm.band_z = waist_plane - lm.band_h
|
||||
lm.cuff_top = ankle_plane + lm.cuff_h
|
||||
log(f"landmarks: waist={lm.waist_z:.3f} hem={lm.hem_z:.3f} "
|
||||
f"span={lm.span:.3f} hip_x={lm.hip_x:.3f} band_z={lm.band_z:.3f} "
|
||||
f"cuff_top={lm.cuff_top:.3f} sh={lm.sh:.2f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tapered offset — the jogger silhouette
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _smoothstep(u):
|
||||
u = min(max(u, 0.0), 1.0)
|
||||
return u * u * (3.0 - 2.0 * u)
|
||||
|
||||
|
||||
def tapered_offset(shell, knee_z, cuff_top, off_hi, off_mid, off_cuff,
|
||||
step_w):
|
||||
"""Per-vertex outward offset along smoothed normals: `off_hi` above the
|
||||
knee, smoothstep taper to `off_mid` at the cuff top, then a feathered step
|
||||
down to the snug `off_cuff` inside the cuff band. The step reads as the
|
||||
cuff ledge; the A-region tint + painted rib carry the rest."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.normal_update()
|
||||
denom = max(knee_z - cuff_top, 1e-6)
|
||||
lo = hi = None
|
||||
for v in bm.verts:
|
||||
z = v.co.z
|
||||
t = off_hi + (off_mid - off_hi) * _smoothstep((knee_z - z) / denom)
|
||||
s = _smoothstep((cuff_top + step_w - z) / step_w)
|
||||
t = t + (off_cuff - t) * s
|
||||
v.co += v.normal * t
|
||||
lo = t if lo is None else min(lo, t)
|
||||
hi = t if hi is None else max(hi, t)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"tapered offset: {hi * 1000:.1f} mm (thigh) -> {lo * 1000:.1f} mm "
|
||||
f"(cuff); knee_z={knee_z:.3f} cuff_top={cuff_top:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Jogger feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _jogger_field(px, py, pz, lm):
|
||||
"""Evaluate jogger features at texel 3D positions (numpy arrays).
|
||||
|
||||
Returns (front, in_band, in_cuff, stripe, arc): `stripe` feeds the mask B
|
||||
channel, `in_band`/`in_cuff` feed R/A, `arc` is the azimuth arc-length
|
||||
coordinate around the per-z leg axis used by the rib paint.
|
||||
"""
|
||||
front = py * FRONT_Y_SIGN > 0.004
|
||||
in_band = pz >= lm.band_z
|
||||
in_cuff = pz <= lm.cuff_top
|
||||
mid = (~in_band) & (~in_cuff)
|
||||
|
||||
# Per-z leg axis, mirrored by x sign (denim.LegLandmarks control points).
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
|
||||
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
r = np.hypot(dx, dy) + 1e-9
|
||||
|
||||
# Side stripe: azimuth toward the outer (+/-x) direction; constant metric
|
||||
# width via arc distance (same construction as the denim outseam).
|
||||
arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
|
||||
stripe = mid & (arc_out < 0.5 * STRIPE_W_M * lm.sh)
|
||||
|
||||
# Rib coordinate: signed azimuth arc length (0 at the outseam; the +/-pi
|
||||
# wrap sits at the inner ankle / body centre where it cannot be seen).
|
||||
arc = np.arctan2(dy, dx * side) * r
|
||||
return front, in_band, in_cuff, stripe, arc
|
||||
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
front, in_band, in_cuff, stripe, arc = _jogger_field(px, py, pz, lm)
|
||||
|
||||
# --- albedo ------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = FLEECE_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
|
||||
# Side stripe: bright, low-noise (crisp edge carries the sport read).
|
||||
for c in range(3):
|
||||
alb[stripe, c] = STRIPE_RGB[c] + 0.5 * noise[stripe]
|
||||
|
||||
if not PLAIN:
|
||||
# Cuff knit rib: alternating shade stripes, constant metric width.
|
||||
rib_dark = (np.floor(arc / (RIB_PERIOD_M * lm.sh)).astype(np.int64)
|
||||
% 2) == 0
|
||||
cuff_f = np.where(rib_dark, RIB_LO, 1.0) * CUFF_SHADE
|
||||
alb[in_cuff, :3] *= cuff_f[in_cuff, None]
|
||||
|
||||
# Waistband elastic rib: same construction, subtler.
|
||||
band_dark = (np.floor(arc / (BAND_RIB_PERIOD_M * lm.sh))
|
||||
.astype(np.int64) % 2) == 0
|
||||
band_f = np.where(band_dark, BAND_RIB_LO, 1.0) * BAND_SHADE
|
||||
alb[in_band, :3] *= band_f[in_band, None]
|
||||
|
||||
# Border stitching: darker lines under the band and above the cuff.
|
||||
w2 = lm.seam_w * 0.5
|
||||
stitch = (np.abs(pz - lm.band_z) < w2) | (np.abs(pz - lm.cuff_top) < w2)
|
||||
alb[stitch, :3] *= STITCH_SHADE
|
||||
|
||||
# Drawstring: two eyelets on the band + two slanted hanging cords.
|
||||
ex = CORD_X_M * lm.sh
|
||||
eyelet_z = lm.band_z + 0.35 * lm.band_h
|
||||
eyelets = front & (
|
||||
np.hypot(np.abs(px) - ex, pz - eyelet_z) < EYELET_R_M * lm.sh)
|
||||
drop = CORD_DROP_FRAC * lm.span
|
||||
hang = np.clip(eyelet_z - pz, 0.0, None)
|
||||
cord_cx = ex + CORD_SLANT * hang
|
||||
cords = front & (pz > eyelet_z - drop) & (pz <= eyelet_z) \
|
||||
& (np.abs(np.abs(px) - cord_cx) < 0.5 * CORD_W_M * lm.sh)
|
||||
for c in range(3):
|
||||
alb[eyelets | cords, c] = CORD_RGB[c]
|
||||
else:
|
||||
alb[in_cuff, :3] *= CUFF_SHADE
|
||||
alb[in_band, :3] *= BAND_SHADE
|
||||
|
||||
# --- region mask: band R / legs G / stripe B / cuffs A -------------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_b = stripe & ~in_band & ~in_cuff
|
||||
is_g = ~(in_band | in_cuff | is_b)
|
||||
mask[in_band, 0] = 1.0
|
||||
mask[is_g, 1] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[in_cuff, 3] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the jogger field, write albedo + mask together (same rasterizer
|
||||
construction as the denim companion; the paint field is what differs)."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = FLEECE_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = legs green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"jogger_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"jogger_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_jogger_shell(body_dir, out_dir, body):
|
||||
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
|
||||
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell)
|
||||
|
||||
zs = [v.co.z for v in shell.data.vertices]
|
||||
waist_z, ankle_z = max(zs), min(zs)
|
||||
lm = denim.LegLandmarks(armature, waist_z, ankle_z, crotch_z + 0.005)
|
||||
|
||||
# Full-length hem AT the ankle joint (calf tail); rim teeth flattened onto
|
||||
# clean planes pre-offset (denim practice — no jagged silhouette).
|
||||
ankle_plane = float(lm.leg_z_pts[0])
|
||||
waist_plane, ankle_plane = denim.flatten_open_rims(shell, ankle_plane)
|
||||
|
||||
span = waist_plane - ankle_plane
|
||||
knee_z = float(lm.leg_z_pts[1]) # calf head
|
||||
cuff_top = ankle_plane + CUFF_FRAC * span
|
||||
tapered_offset(shell, knee_z, cuff_top, OFFSET_HI_M, OFFSET_MID_M,
|
||||
OFFSET_CUFF_M, CUFF_STEP_W_M)
|
||||
denim.waist_flare(shell, waist_plane, BAND_FRAC * span, WAIST_FLARE_M)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
denim.clamp_waist_residue(shell, waist_plane)
|
||||
|
||||
# Landmarks reference the CLEAN rims for the paint/mask pass.
|
||||
finalize_jogger_landmarks(lm, waist_plane, ankle_plane, BAND_FRAC,
|
||||
CUFF_FRAC)
|
||||
denim.author_parked_uv2(shell)
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global OFFSET_HI_M, OFFSET_MID_M, OFFSET_CUFF_M, BAND_FRAC, CUFF_FRAC
|
||||
global STRIPE_W_M, FLEECE_RGB, STRIPE_RGB, WAIST_FLARE_M, NOISE_SEED, PLAIN
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--mid-offset M] [--cuff-offset M] "
|
||||
"[--band-frac F] [--cuff-frac F] [--stripe-width M] "
|
||||
"[--fabric r,g,b] [--stripe-rgb r,g,b] [--waist-flare M] "
|
||||
"[--seed N] [--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
if "--offset" in argv:
|
||||
OFFSET_HI_M = float(argv[argv.index("--offset") + 1])
|
||||
if "--mid-offset" in argv:
|
||||
OFFSET_MID_M = float(argv[argv.index("--mid-offset") + 1])
|
||||
if "--cuff-offset" in argv:
|
||||
OFFSET_CUFF_M = float(argv[argv.index("--cuff-offset") + 1])
|
||||
if "--band-frac" in argv:
|
||||
BAND_FRAC = float(argv[argv.index("--band-frac") + 1])
|
||||
if "--cuff-frac" in argv:
|
||||
CUFF_FRAC = float(argv[argv.index("--cuff-frac") + 1])
|
||||
if "--stripe-width" in argv:
|
||||
STRIPE_W_M = float(argv[argv.index("--stripe-width") + 1])
|
||||
if "--fabric" in argv:
|
||||
FLEECE_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--fabric") + 1].split(","))
|
||||
if "--stripe-rgb" in argv:
|
||||
STRIPE_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--stripe-rgb") + 1].split(","))
|
||||
if "--waist-flare" in argv:
|
||||
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
|
||||
if "--seed" in argv:
|
||||
NOISE_SEED = int(argv[argv.index("--seed") + 1])
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"joggers per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{OFFSET_HI_M * 1000:.0f}->{OFFSET_CUFF_M * 1000:.1f} mm, "
|
||||
f"band_frac={BAND_FRAC} cuff_frac={CUFF_FRAC} "
|
||||
f"stripe={STRIPE_W_M * 1000:.0f} mm, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_jogger_shell(body_dir, out_dir, body)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,524 @@
|
||||
"""
|
||||
blender_author_lower_shell.py (T-1089, lower-body offset-shell companion)
|
||||
|
||||
Author LOWER-BODY offset-shell garments (the pants family) per body, reusing
|
||||
blender_author_offset_shell.py as a library (scene build, offset, solidify,
|
||||
raster helpers, export). The base script stays the torso/t-shirt reference;
|
||||
this companion parameterizes the lower-body family so pants_formal, jeans,
|
||||
shorts, joggers etc. are all CLI parameter sets over ONE code path — reusable
|
||||
parameters over one-off hacks.
|
||||
|
||||
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r.
|
||||
Natural boundaries give the waist opening (top of seg_hips) and ankle hems
|
||||
(bottom of seg_leg_lower) for free; a leg cut is only applied for --leg-frac
|
||||
< 1.0 (shorts family).
|
||||
|
||||
Region mask (RGBA, UV0-aligned — toon_garment.gdshader convention):
|
||||
R = waistband (top band of the hips, height derived from the pelvis bone)
|
||||
G = legs (everything else — the main fabric region)
|
||||
B = cuffs (optional, --cuff-frac > 0; joggers)
|
||||
|
||||
Painted texture identity (baked into the per-body albedo, luma-carried so it
|
||||
survives any region tint — the shader replaces hue but keeps luminance):
|
||||
--crease subtle LIGHT front crease line down each leg (formal press line),
|
||||
drawn by intersecting the shell with each leg's hip->knee->ankle
|
||||
axis plane and rasterizing the crossing segments in UV space.
|
||||
--seam subtle DARK horizontal seam line at the waistband boundary.
|
||||
|
||||
Per-body mode only (route guidance from Q-060 / 216-capture QA evidence:
|
||||
offset-shell garments author per body; weights inherited by construction).
|
||||
|
||||
Usage:
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_lower_shell.py -- \
|
||||
<bodies_root> <out_dir> \
|
||||
[--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \
|
||||
[--waistband-frac 1.0] [--cuff-frac 0.0] \
|
||||
[--fabric R,G,B] [--fabric-noise 0.012] \
|
||||
[--no-crease] [--crease-gain 0.10] [--no-seam] [--seam-gain 0.05] \
|
||||
[--seed 1093]
|
||||
|
||||
Writes per body:
|
||||
<out_dir>/<body>.glb skinned garment authored on that body
|
||||
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0 layout)
|
||||
Plus:
|
||||
<out_dir>/base_albedo.png reference body's albedo (sidecar; the
|
||||
painted albedo is embedded per GLB, and
|
||||
the Godot importer extracts it per body
|
||||
as <body>_base_albedo.png)
|
||||
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
|
||||
# Make the sibling base module importable inside Blender.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import blender_author_offset_shell as base # noqa: E402
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Lower-body garment parameters (CLI-overridable)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
LOWER_SEGMENTS = [
|
||||
"seg_hips",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
]
|
||||
|
||||
DEFAULTS = {
|
||||
"offset": 0.010, # slim silhouette standoff (m); per-body authoring
|
||||
# guarantees clearance by construction, and bottoms
|
||||
# sit UNDER tops (t-shirt hem = 12 mm), so 10 mm
|
||||
# keeps the layering order correct at the waist.
|
||||
"leg_frac": 1.0, # fraction of hip->ankle length kept (1.0 = full)
|
||||
"waistband_frac": 1.0, # waistband starts at pelvis head + frac*bone_len
|
||||
"cuff_frac": 0.0, # B-region cuff height as fraction of knee->ankle
|
||||
"fabric": (0.145, 0.147, 0.160), # dark charcoal, slight cool cast
|
||||
"fabric_noise": 0.012, # +/- albedo jitter, subtle woven feel
|
||||
"crease": True, # painted front crease line per leg
|
||||
"crease_gain": 0.10, # luma lift along the crease (subtle press line)
|
||||
"seam": True, # painted seam at the waistband boundary
|
||||
"seam_gain": 0.05, # luma drop along the seam
|
||||
"seed": 1093, # deterministic albedo noise
|
||||
}
|
||||
|
||||
CREASE_TOP_MARGIN = 0.12 # crease starts this fraction below the hip joint
|
||||
CREASE_NORMAL_MIN = 0.35 # face must point forward this much for the crease
|
||||
LINE_RADIUS_PX = 0.9 # painted line half-width in texels
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[lower-shell] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Threshold derivation (bone landmarks; the 11 bodies share the 65-bone rig)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def derive_lower_thresholds(armature, p):
|
||||
"""Derive waistband/cuff z-planes and per-leg axis polylines.
|
||||
|
||||
Anchors: pelvis (waistband band), thigh_* head (hip joint), thigh_* tail /
|
||||
calf_* head (knee), calf_* tail (ankle). All in body-local metres so each
|
||||
body derives its OWN proportionally-consistent parameters.
|
||||
"""
|
||||
bones = armature.data.bones
|
||||
pelvis = bones.get("pelvis")
|
||||
need = ["thigh_l", "thigh_r", "calf_l", "calf_r"]
|
||||
legs = {}
|
||||
for side in ("l", "r"):
|
||||
thigh = bones.get(f"thigh_{side}")
|
||||
calf = bones.get(f"calf_{side}")
|
||||
if thigh is None or calf is None:
|
||||
raise RuntimeError(f"landmark bones missing for leg _{side} ({need})")
|
||||
# Polyline hip -> knee -> ankle in the XZ plane (rest pose, z-up).
|
||||
legs[side] = {
|
||||
"hip": (thigh.head_local.x, thigh.head_local.z),
|
||||
"knee": (thigh.tail_local.x, thigh.tail_local.z),
|
||||
"ankle": (calf.tail_local.x, calf.tail_local.z),
|
||||
}
|
||||
if pelvis is None:
|
||||
raise RuntimeError("pelvis bone missing — cannot derive waistband")
|
||||
|
||||
pelvis_len = pelvis.tail_local.z - pelvis.head_local.z
|
||||
wb_z = pelvis.head_local.z + p["waistband_frac"] * pelvis_len
|
||||
|
||||
hip_z = (legs["l"]["hip"][1] + legs["r"]["hip"][1]) / 2.0
|
||||
ankle_z = (legs["l"]["ankle"][1] + legs["r"]["ankle"][1]) / 2.0
|
||||
knee_z = (legs["l"]["knee"][1] + legs["r"]["knee"][1]) / 2.0
|
||||
leg_len = hip_z - ankle_z
|
||||
|
||||
cut_z = None
|
||||
if p["leg_frac"] < 0.999:
|
||||
cut_z = hip_z - p["leg_frac"] * leg_len
|
||||
|
||||
cuff_z = None
|
||||
if p["cuff_frac"] > 1e-6:
|
||||
cuff_z = ankle_z + p["cuff_frac"] * (knee_z - ankle_z)
|
||||
|
||||
thr = {
|
||||
"wb_z": wb_z,
|
||||
"cut_z": cut_z,
|
||||
"cuff_z": cuff_z,
|
||||
"legs": legs,
|
||||
"hip_z": hip_z,
|
||||
"ankle_z": ankle_z,
|
||||
"leg_len": leg_len,
|
||||
}
|
||||
log(f"thresholds: waistband z>={wb_z:.3f} "
|
||||
f"cut_z={cut_z if cut_z is None else round(cut_z, 3)} "
|
||||
f"cuff_z={cuff_z if cuff_z is None else round(cuff_z, 3)} "
|
||||
f"hip_z={hip_z:.3f} ankle_z={ankle_z:.3f}")
|
||||
return thr
|
||||
|
||||
|
||||
def _leg_axis_x(leg, z):
|
||||
"""Piecewise-linear x of the leg axis at height z (clamped to endpoints)."""
|
||||
hx, hz = leg["hip"]
|
||||
kx, kz = leg["knee"]
|
||||
ax, az = leg["ankle"]
|
||||
if z >= hz:
|
||||
return hx
|
||||
if z <= az:
|
||||
return ax
|
||||
if z >= kz:
|
||||
t = (hz - z) / max(hz - kz, 1e-6)
|
||||
return hx + t * (kx - hx)
|
||||
t = (kz - z) / max(kz - az, 1e-6)
|
||||
return kx + t * (ax - kx)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Seam weld (pre-offset)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_seams(shell):
|
||||
"""Weld coincident verts before offsetting.
|
||||
|
||||
glTF import splits vertices along UV seams (and the joined segment
|
||||
boundaries duplicate their shared rings), so offsetting each copy along
|
||||
its OWN split normal tears the shell open — on the pants this showed as a
|
||||
skin-coloured slit down the front-centre seam of the hips. Welding fuses
|
||||
the copies so the offset moves one vertex along one averaged normal.
|
||||
Per-loop UVs are untouched (the UV seam itself survives); coincident
|
||||
copies carry identical bone weights by construction, so skinning is
|
||||
unaffected.
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=1e-4)
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"welded seams: {before} -> {len(shell.data.vertices)} verts")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Leg cut (shorts family; skipped at leg_frac 1.0)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def leg_cut(shell, cut_z):
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
doomed = [v for v in bm.verts if v.co.z < cut_z]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"leg cut at z={cut_z:.3f}: removed {len(doomed)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Painted albedo (charcoal base + crease/seam lines, luma-carried identity)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _plane_crossings_uv(face, uv_layer, dist_fn):
|
||||
"""UV points where the face's edge loop crosses dist_fn(co) == 0."""
|
||||
loops = face.loops[:]
|
||||
n = len(loops)
|
||||
pts = []
|
||||
for i in range(n):
|
||||
la, lb = loops[i], loops[(i + 1) % n]
|
||||
d1 = dist_fn(la.vert.co)
|
||||
d2 = dist_fn(lb.vert.co)
|
||||
if (d1 > 0.0) == (d2 > 0.0):
|
||||
continue
|
||||
denom = d1 - d2
|
||||
if abs(denom) < 1e-9:
|
||||
continue
|
||||
t = d1 / denom
|
||||
uv1 = la[uv_layer].uv
|
||||
uv2 = lb[uv_layer].uv
|
||||
pts.append((uv1[0] + t * (uv2[0] - uv1[0]),
|
||||
uv1[1] + t * (uv2[1] - uv1[1])))
|
||||
return pts
|
||||
|
||||
|
||||
def _stamp_uv_line(hits, a, b, W, H):
|
||||
"""Mark texels along UV segment a->b into boolean mask `hits`."""
|
||||
ax, ay = a[0] * (W - 1), a[1] * (H - 1)
|
||||
bx, by = b[0] * (W - 1), b[1] * (H - 1)
|
||||
steps = int(max(abs(bx - ax), abs(by - ay)) * 2.0) + 1
|
||||
r = LINE_RADIUS_PX
|
||||
for i in range(steps + 1):
|
||||
t = i / steps
|
||||
px = ax + (bx - ax) * t
|
||||
py = ay + (by - ay) * t
|
||||
x0 = max(int(np.floor(px - r)), 0)
|
||||
x1 = min(int(np.ceil(px + r)), W - 1)
|
||||
y0 = max(int(np.floor(py - r)), 0)
|
||||
y1 = min(int(np.ceil(py + r)), H - 1)
|
||||
for yy in range(y0, y1 + 1):
|
||||
for xx in range(x0, x1 + 1):
|
||||
if (xx - px) ** 2 + (yy - py) ** 2 <= r * r + 0.25:
|
||||
hits[yy, xx] = True
|
||||
|
||||
|
||||
def bake_painted_albedo(shell, thr, p):
|
||||
"""Charcoal fabric + painted crease/seam lines, baked in UV0 space.
|
||||
|
||||
The toon_garment shader keeps only albedo LUMINANCE under region tints, so
|
||||
identity painted here (light crease, dark seam) survives any recolor.
|
||||
"""
|
||||
W = H = base.ALBEDO_SIZE
|
||||
rng = np.random.default_rng(p["seed"])
|
||||
fabric = np.array(p["fabric"], dtype=np.float32)
|
||||
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * p["fabric_noise"]
|
||||
rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.normal_update()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for albedo bake")
|
||||
|
||||
crease_hits = np.zeros((H, W), dtype=bool)
|
||||
seam_hits = np.zeros((H, W), dtype=bool)
|
||||
crease_faces = 0
|
||||
seam_faces = 0
|
||||
|
||||
crease_top = thr["hip_z"] - CREASE_TOP_MARGIN * thr["leg_len"]
|
||||
for face in bm.faces:
|
||||
center = face.calc_center_median()
|
||||
# Waistband seam: any face crossing the wb_z plane (full ring).
|
||||
if p["seam"]:
|
||||
pts = _plane_crossings_uv(face, uv_layer,
|
||||
lambda co: co.z - thr["wb_z"])
|
||||
if len(pts) >= 2:
|
||||
_stamp_uv_line(seam_hits, pts[0], pts[1], W, H)
|
||||
seam_faces += 1
|
||||
# Front crease: forward-facing faces crossing the leg-axis plane.
|
||||
if p["crease"]:
|
||||
if face.normal.y * base.FRONT_Y_SIGN < CREASE_NORMAL_MIN:
|
||||
continue
|
||||
if center.z > crease_top or center.z < thr["ankle_z"]:
|
||||
continue
|
||||
leg = thr["legs"]["l"] if center.x >= 0.0 else thr["legs"]["r"]
|
||||
pts = _plane_crossings_uv(
|
||||
face, uv_layer, lambda co: co.x - _leg_axis_x(leg, co.z))
|
||||
if len(pts) >= 2:
|
||||
_stamp_uv_line(crease_hits, pts[0], pts[1], W, H)
|
||||
crease_faces += 1
|
||||
bm.free()
|
||||
|
||||
if p["seam"]:
|
||||
rgb[seam_hits, :] = np.clip(rgb[seam_hits, :] - p["seam_gain"], 0.0, 1.0)
|
||||
if p["crease"]:
|
||||
rgb[crease_hits, :] = np.clip(rgb[crease_hits, :] + p["crease_gain"], 0.0, 1.0)
|
||||
log(f"painted albedo: crease faces={crease_faces} "
|
||||
f"({int(crease_hits.sum())} px) seam faces={seam_faces} "
|
||||
f"({int(seam_hits.sum())} px)")
|
||||
if p["crease"] and crease_faces == 0:
|
||||
log("WARNING: crease painted on 0 faces — check leg axis / front sign")
|
||||
|
||||
rgba = np.concatenate(
|
||||
[rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
|
||||
img = bpy.data.images.new("garment_lower_albedo", W, H, alpha=False)
|
||||
img.pixels.foreach_set(rgba.reshape(-1))
|
||||
img.update()
|
||||
return img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Region mask (waistband R / legs G / optional cuff B)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _classify_lower(center, thr):
|
||||
if center.z >= thr["wb_z"]:
|
||||
return (1.0, 0.0, 0.0, 0.0) # waistband -> R (tint_0)
|
||||
if thr["cuff_z"] is not None and center.z <= thr["cuff_z"]:
|
||||
return (0.0, 0.0, 1.0, 0.0) # cuff -> B (tint_2)
|
||||
return (0.0, 1.0, 0.0, 0.0) # legs -> G (tint_1)
|
||||
|
||||
|
||||
def bake_lower_mask(shell, out_path, thr):
|
||||
W = H = base.MASK_SIZE
|
||||
buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
buf[:, :, 1] = 1.0 # green background = legs (main region)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for region mask bake")
|
||||
|
||||
counts = {"waistband": 0, "legs": 0, "cuff": 0}
|
||||
for face in bm.faces:
|
||||
color = _classify_lower(face.calc_center_median(), thr)
|
||||
if color[0] > 0.5:
|
||||
counts["waistband"] += 1
|
||||
elif color[2] > 0.5:
|
||||
counts["cuff"] += 1
|
||||
else:
|
||||
counts["legs"] += 1
|
||||
for a, b, c in base._tris_from_face(face, uv_layer):
|
||||
base._raster_tri(buf, a, b, c, color, W, H)
|
||||
bm.free()
|
||||
total = max(sum(counts.values()), 1)
|
||||
log("region faces: " + " ".join(
|
||||
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
|
||||
if counts["waistband"] == 0:
|
||||
log("WARNING: waistband region empty — check waistband_frac / pelvis")
|
||||
|
||||
img = bpy.data.images.new("garment_lower_mask", W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = out_path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
log(f"baked region mask -> {out_path}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parked logo UV2 (pants are not logo-capable; keep the channel guarded)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_parked_logo_uv(shell):
|
||||
me = shell.data
|
||||
while len(me.uv_layers) > 1:
|
||||
me.uv_layers.remove(me.uv_layers[-1])
|
||||
logo_uv = me.uv_layers.new(name="logo_uv")
|
||||
me.uv_layers.active = me.uv_layers[0]
|
||||
data = logo_uv.data
|
||||
parked = [2.0, 2.0] * len(data)
|
||||
logo_uv.data.foreach_set("uv", parked)
|
||||
me.update()
|
||||
log(f"logo UV2 parked outside [0,1] on all {len(data)} loops")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_lower_shell(body_dir, out_dir, body, p):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = LOWER_SEGMENTS # reuse the base segment importer
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
thr = derive_lower_thresholds(armature, p)
|
||||
|
||||
zs = [v.co.z for v in shell.data.vertices]
|
||||
log(f"shell z-range: {min(zs):.3f}..{max(zs):.3f}")
|
||||
if thr["wb_z"] >= max(zs):
|
||||
log("WARNING: waistband plane above shell top — band will be empty")
|
||||
|
||||
weld_seams(shell)
|
||||
if thr["cut_z"] is not None:
|
||||
leg_cut(shell, thr["cut_z"])
|
||||
base.offset_outward(shell, p["offset"])
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
|
||||
albedo_img = bake_painted_albedo(shell, thr, p)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
author_parked_logo_uv(shell)
|
||||
bake_lower_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr)
|
||||
|
||||
# Save under the SHARED sidecar name for every body: the glTF exporter
|
||||
# names the embedded image after this filepath, and the Godot importer
|
||||
# extracts embedded textures as <glb>_<imagename>.png — so this yields the
|
||||
# per-body <body>_base_albedo.png convention (as tshirt_modern) on import.
|
||||
# The loop authors the reference body LAST so base_albedo.png ends up as
|
||||
# the reference albedo (per-body albedos differ: painted crease follows
|
||||
# each body's own geometry).
|
||||
base.save_albedo_sidecar(
|
||||
albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Entry
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def parse_args(argv):
|
||||
p = dict(DEFAULTS)
|
||||
# Reference body last: the shared base_albedo.png sidecar is rewritten per
|
||||
# body, so ordering makes the surviving copy the reference body's.
|
||||
bodies = [b for b in base.BODY_TYPES if b != base.REFERENCE_BODY]
|
||||
bodies.append(base.REFERENCE_BODY)
|
||||
|
||||
def _val(flag):
|
||||
return argv[argv.index(flag) + 1]
|
||||
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in _val("--bodies").split(",")]
|
||||
for flag, key, cast in [
|
||||
("--offset", "offset", float),
|
||||
("--leg-frac", "leg_frac", float),
|
||||
("--waistband-frac", "waistband_frac", float),
|
||||
("--cuff-frac", "cuff_frac", float),
|
||||
("--fabric-noise", "fabric_noise", float),
|
||||
("--crease-gain", "crease_gain", float),
|
||||
("--seam-gain", "seam_gain", float),
|
||||
("--seed", "seed", int),
|
||||
]:
|
||||
if flag in argv:
|
||||
p[key] = cast(_val(flag))
|
||||
if "--fabric" in argv:
|
||||
p["fabric"] = tuple(float(s) for s in _val("--fabric").split(","))
|
||||
if "--no-crease" in argv:
|
||||
p["crease"] = False
|
||||
if "--no-seam" in argv:
|
||||
p["seam"] = False
|
||||
return p, bodies
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
p, bodies = parse_args(argv)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
log(f"lower-shell per-body: {len(bodies)} bodies, offset "
|
||||
f"{p['offset']*1000:.0f} mm, leg_frac {p['leg_frac']}, "
|
||||
f"fabric {p['fabric']}")
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_lower_shell(body_dir, out_dir, body, p)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
blender_author_offset_coverall.py (T-1089, uniform_utility — route (c) proof)
|
||||
|
||||
Full-body COVERALL authored via the offset-shell technique, per body. Companion
|
||||
to blender_author_offset_shell.py (imported as a module): reuses its scene
|
||||
plumbing, offset/solidify, logo-UV2 authoring, rasterizer and export — and
|
||||
extends it with the coverall-specific geometry and the four-region utility
|
||||
mask that this garment exists to prove:
|
||||
|
||||
ONE garment covering torso + torso_upper + arms + hips + legs. The upper and
|
||||
lower shells join at the waist for free: adjacent body segments duplicate a
|
||||
coincident overlap band (probe: median nearest-vertex distance 0.0 in every
|
||||
seam band), so the joined mesh has no gap by construction and the duplicated
|
||||
faces offset identically (same verts, same normals, same atlas UVs) and
|
||||
render invisibly.
|
||||
|
||||
Geometry beyond the base script (parameterised, not hard-coded):
|
||||
- WRIST cut: trim lowerarm tubes at a fraction along the lowerarm bone
|
||||
(full sleeve ending in a cuff above the hand).
|
||||
- ANKLE cut: trim calf tubes at a fraction along the calf bone (leg ends in
|
||||
a cuff above the boot line; feet stay free for footwear garments).
|
||||
Both are bone-landmark fractions, so every body derives its own planes.
|
||||
|
||||
Region mask (the multi-region showcase, R/G/B/A -> tint_0..3):
|
||||
G = main body fabric
|
||||
R = trim: collar band + arm cuffs + leg cuffs
|
||||
B = belt line (waist band, hides the segment seam) + chest patch (logo box,
|
||||
logo-capable) + right-thigh utility patch
|
||||
A = shoulder marks (top-facing epaulette strap on each shoulder)
|
||||
|
||||
Painted albedo details (flat, toon-friendly; identity lives in the texture):
|
||||
per-region flat luminance fills (dark belt webbing, bright patches, mid
|
||||
shoulder marks), a brighter buckle plate at the front-centre of the belt,
|
||||
and dark stitch lines rasterised along every region-boundary edge (patch
|
||||
borders, collar seam, cuff seams, belt edges). The toon_garment shader is
|
||||
luminance-preserving, so these survive any region recolor.
|
||||
|
||||
Per-body only (this garment ships per-body per the Q-060 route guidance):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_offset_coverall.py -- \
|
||||
<bodies_root> <out_dir> [--bodies a,b,c] [--offset 0.012]
|
||||
|
||||
Writes per body:
|
||||
<out_dir>/<body>.glb skinned coverall authored on that body
|
||||
<out_dir>/<body>_mask.png RGBA region mask (that body's atlas UVs)
|
||||
<out_dir>/<body>_base_albedo.png painted detail albedo (also in the GLB)
|
||||
Plus:
|
||||
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
|
||||
<out_dir>/base_albedo.png copy of average_m's albedo (convention)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
|
||||
(per-body authoring for offset shells).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
|
||||
# Make the sibling base module importable when Blender runs this file directly.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import blender_author_offset_shell as base # noqa: E402
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Coverall parameters (all bone-landmark fractions unless noted)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
"seg_hips",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
]
|
||||
|
||||
WRIST_CUT_FRAC = 0.88 # keep this fraction of the lowerarm (elbow->wrist)
|
||||
ANKLE_CUT_FRAC = 0.82 # keep this fraction of the calf (knee->ankle)
|
||||
CUFF_ARM_START_FRAC = 0.60 # cuff band = beyond this fraction of the lowerarm
|
||||
CUFF_LEG_START_FRAC = 0.56 # cuff band = beyond this fraction of the calf
|
||||
|
||||
# Belt: centred between pelvis head and spine_01 head (the torso|hips seam
|
||||
# band sits at ~0.90 of that span on average_m), half-height in ref metres
|
||||
# scaled by each body's pelvis->spine_01 span.
|
||||
BELT_CENTER_FRAC = 0.90
|
||||
BELT_HALF_M_REF = 0.032
|
||||
BUCKLE_X_ABS_REF = 0.045 # front-centre belt faces within this |x| = buckle plate
|
||||
|
||||
# Shoulder marks: top-facing band above the upperarm head, outside the collar.
|
||||
SHOULDER_Z_FRAC = 0.18 # of (neck head z - upperarm head z), above upperarm head
|
||||
SHOULDER_X_MAX_FRAC = 1.38 # of shoulder |x|
|
||||
SHOULDER_NORMAL_Z_MIN = 0.45 # surface must point upward
|
||||
|
||||
# Chest patch: the logo box inset by this fraction of its width/height per
|
||||
# side, so the amber patch frames the decal instead of touching the box edge.
|
||||
CHEST_PATCH_INSET_FRAC = 0.10
|
||||
|
||||
# Right-thigh utility patch: box along the thigh bone, front-facing.
|
||||
THIGH_PATCH_SIDE = "thigh_r"
|
||||
THIGH_PATCH_Z_FRACS = (0.30, 0.58) # fraction down the thigh bone
|
||||
THIGH_PATCH_HALF_X_REF = 0.058
|
||||
THIGH_PATCH_NORMAL_Y_MIN = 0.10 # forward-facing (front = -Y, base.FRONT_Y_SIGN)
|
||||
|
||||
# Painted-albedo luminance per region label (flat toon fills).
|
||||
ALBEDO_LUMA = {
|
||||
"body": 0.62,
|
||||
"collar": 0.66,
|
||||
"cuff": 0.66,
|
||||
"shoulder": 0.50,
|
||||
"belt": 0.34,
|
||||
"buckle": 0.82,
|
||||
"patch": 0.72,
|
||||
}
|
||||
STITCH_LUMA = 0.30 # dark seam/stitch lines on region boundaries
|
||||
ALBEDO_NOISE = 0.03 # +/- woven-feel jitter (matches the base script)
|
||||
|
||||
# Label ids index the vectorised classifier's output arrays.
|
||||
LABELS = ["body", "collar", "cuff", "shoulder", "belt", "buckle", "patch"]
|
||||
LABEL_ID = {name: i for i, name in enumerate(LABELS)}
|
||||
LABEL_RGBA = {
|
||||
"collar": (1.0, 0.0, 0.0, 0.0), # R trim
|
||||
"cuff": (1.0, 0.0, 0.0, 0.0), # R trim
|
||||
"body": (0.0, 1.0, 0.0, 0.0), # G main fabric
|
||||
"belt": (0.0, 0.0, 1.0, 0.0), # B belt + patches
|
||||
"buckle": (0.0, 0.0, 1.0, 0.0), # B
|
||||
"patch": (0.0, 0.0, 1.0, 0.0), # B
|
||||
"shoulder": (0.0, 0.0, 0.0, 1.0), # A shoulder marks
|
||||
}
|
||||
LABEL_RGBA_ARR = np.array([LABEL_RGBA[n] for n in LABELS], dtype=np.float32)
|
||||
LABEL_LUMA_ARR = np.array([ALBEDO_LUMA[n] for n in LABELS], dtype=np.float32)
|
||||
|
||||
_REF_SHOULDER_X = 0.1919 # average_m upperarm head |x| (same anchor as base)
|
||||
|
||||
log = base.log
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Threshold derivation (extends base.derive_thresholds with coverall zones)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def derive_coverall_thresholds(armature):
|
||||
thr = base.derive_thresholds(armature)
|
||||
bones = armature.data.bones
|
||||
|
||||
def bone(name):
|
||||
b = bones.get(name)
|
||||
if b is None:
|
||||
raise RuntimeError(f"landmark bone {name} missing")
|
||||
return b
|
||||
|
||||
ua = bone("upperarm_l")
|
||||
neck = bone("neck_01")
|
||||
pelvis = bone("pelvis")
|
||||
spine01 = bone("spine_01")
|
||||
shoulder_x = abs(ua.head_local.x)
|
||||
scale = shoulder_x / _REF_SHOULDER_X
|
||||
|
||||
# Wrist cut planes + arm cuff start, per side (arm runs along +/-X).
|
||||
for side, sign in (("l", +1), ("r", -1)):
|
||||
la = bone(f"lowerarm_{side}")
|
||||
hx, tx = la.head_local.x, la.tail_local.x
|
||||
thr[f"wrist_cut_x_{side}"] = hx + WRIST_CUT_FRAC * (tx - hx)
|
||||
thr[f"cuff_arm_x_{side}"] = hx + CUFF_ARM_START_FRAC * (tx - hx)
|
||||
# Ankle cut + leg cuff start (leg runs down -Z; both calves share z).
|
||||
calf = bone("calf_l")
|
||||
hz, tz = calf.head_local.z, calf.tail_local.z
|
||||
thr["ankle_cut_z"] = hz + ANKLE_CUT_FRAC * (tz - hz)
|
||||
thr["cuff_leg_z"] = hz + CUFF_LEG_START_FRAC * (tz - hz)
|
||||
|
||||
# Belt band.
|
||||
pz, sz = pelvis.head_local.z, spine01.head_local.z
|
||||
span = sz - pz
|
||||
thr["belt_z"] = pz + BELT_CENTER_FRAC * span
|
||||
thr["belt_half"] = BELT_HALF_M_REF * scale
|
||||
thr["buckle_x_abs"] = BUCKLE_X_ABS_REF * scale
|
||||
|
||||
# Shoulder marks.
|
||||
uz = ua.head_local.z
|
||||
thr["shoulder_z_min"] = uz + SHOULDER_Z_FRAC * (neck.head_local.z - uz)
|
||||
thr["shoulder_x_max"] = shoulder_x * SHOULDER_X_MAX_FRAC
|
||||
|
||||
# Chest patch = logo box inset a little on every side.
|
||||
cx0, cx1 = thr["chest_x"]
|
||||
cz0, cz1 = thr["chest_z"]
|
||||
dx = (cx1 - cx0) * CHEST_PATCH_INSET_FRAC
|
||||
dz = (cz1 - cz0) * CHEST_PATCH_INSET_FRAC
|
||||
thr["patch_x"] = (cx0 + dx, cx1 - dx)
|
||||
thr["patch_z"] = (cz0 + dz, cz1 - dz)
|
||||
|
||||
# Right-thigh patch box.
|
||||
thigh = bone(THIGH_PATCH_SIDE)
|
||||
thz, ttz = thigh.head_local.z, thigh.tail_local.z
|
||||
f0, f1 = THIGH_PATCH_Z_FRACS
|
||||
thr["thigh_patch_z"] = (thz + f1 * (ttz - thz), thz + f0 * (ttz - thz))
|
||||
tx_ctr = thigh.head_local.x
|
||||
half = THIGH_PATCH_HALF_X_REF * scale
|
||||
thr["thigh_patch_x"] = (tx_ctr - half, tx_ctr + half)
|
||||
|
||||
log(
|
||||
"coverall thresholds: "
|
||||
f"wrist_l x>{thr['wrist_cut_x_l']:.3f} wrist_r x<{thr['wrist_cut_x_r']:.3f} "
|
||||
f"ankle z<{thr['ankle_cut_z']:.3f} "
|
||||
f"belt z={thr['belt_z']:.3f}+/-{thr['belt_half']:.3f} "
|
||||
f"shoulder z>={thr['shoulder_z_min']:.3f} "
|
||||
f"thigh_patch x=({thr['thigh_patch_x'][0]:.3f},{thr['thigh_patch_x'][1]:.3f}) "
|
||||
f"z=({thr['thigh_patch_z'][0]:.3f},{thr['thigh_patch_z'][1]:.3f})"
|
||||
)
|
||||
return thr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Limb cuts (wrists + ankles)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def limb_cuts(shell, thr):
|
||||
"""Delete verts beyond the wrist planes (|X|) and below the ankle plane (Z)."""
|
||||
wl = thr["wrist_cut_x_l"]
|
||||
wr = thr["wrist_cut_x_r"]
|
||||
az = thr["ankle_cut_z"]
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = [
|
||||
v for v in bm.verts
|
||||
if v.co.x > wl or v.co.x < wr or v.co.z < az
|
||||
]
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"limb cuts removed {len(to_delete)} verts "
|
||||
f"(wrist x>{wl:.3f}/x<{wr:.3f}, ankle z<{az:.3f}); "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Region classification (vectorised, per texel)
|
||||
#
|
||||
# Face-granular classification made the patch/belt/shoulder boundaries follow
|
||||
# the triangulation (jagged, torn-looking zones on the first preview). The
|
||||
# bake therefore classifies every TEXEL: barycentric interpolation gives each
|
||||
# covered texel a body-space position + smoothed vertex normal, and the zone
|
||||
# boundaries land exactly where the thresholds say — crisp at mask resolution.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def classify_texels(pos, nrm, thr):
|
||||
"""Classify N texels. pos/nrm are (N,3) body-local arrays.
|
||||
|
||||
Returns (N,) uint8 label ids (indices into LABELS). Precedence: collar,
|
||||
cuffs, shoulder marks, belt/buckle, chest patch, thigh patch, body.
|
||||
"""
|
||||
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
||||
fy = y * base.FRONT_Y_SIGN
|
||||
fwd = nrm[:, 1] * base.FRONT_Y_SIGN
|
||||
ax = np.abs(x)
|
||||
|
||||
lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8)
|
||||
remaining = np.ones(x.shape, dtype=bool)
|
||||
|
||||
def take(cond, name):
|
||||
m = cond & remaining
|
||||
lab[m] = LABEL_ID[name]
|
||||
remaining[m] = False
|
||||
|
||||
take((z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"]), "collar")
|
||||
take((x >= thr["cuff_arm_x_l"]) | (x <= thr["cuff_arm_x_r"]), "cuff")
|
||||
take(z <= thr["cuff_leg_z"], "cuff")
|
||||
take(
|
||||
(z >= thr["shoulder_z_min"])
|
||||
& (ax >= thr["collar_x_abs"]) & (ax <= thr["shoulder_x_max"])
|
||||
& (nrm[:, 2] > SHOULDER_NORMAL_Z_MIN),
|
||||
"shoulder",
|
||||
)
|
||||
belt = np.abs(z - thr["belt_z"]) <= thr["belt_half"]
|
||||
take(belt & (fy > 0.0) & (ax < thr["buckle_x_abs"]), "buckle")
|
||||
take(belt, "belt")
|
||||
px0, px1 = thr["patch_x"]
|
||||
pz0, pz1 = thr["patch_z"]
|
||||
take(
|
||||
(fy > base.CHEST_FRONT_Y)
|
||||
& (x >= px0) & (x <= px1) & (z >= pz0) & (z <= pz1)
|
||||
& (fwd > base.CHEST_NORMAL_Y),
|
||||
"patch",
|
||||
)
|
||||
tx0, tx1 = thr["thigh_patch_x"]
|
||||
tz0, tz1 = thr["thigh_patch_z"]
|
||||
take(
|
||||
(fy > 0.0)
|
||||
& (x >= tx0) & (x <= tx1) & (z >= tz0) & (z <= tz1)
|
||||
& (fwd > THIGH_PATCH_NORMAL_Y_MIN),
|
||||
"patch",
|
||||
)
|
||||
return lab
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Combined mask + painted-albedo bake (one classification pass)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _tri_texels(a, b, c, W, H):
|
||||
"""Texels covered by UV triangle (a,b,c) with barycentric weights.
|
||||
|
||||
Same maths as base._raster_tri, but returns (ys, xs, w0, w1, w2) arrays
|
||||
instead of writing a flat colour, so the caller can interpolate per-texel
|
||||
attributes (position, normal) across the triangle.
|
||||
"""
|
||||
empty = (np.empty(0, int),) * 2 + (np.empty(0, np.float32),) * 3
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return empty
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return empty
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
px = xs + 0.5
|
||||
py = ys + 0.5
|
||||
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
|
||||
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return empty
|
||||
return (
|
||||
ys[inside], xs[inside],
|
||||
w0[inside].astype(np.float32),
|
||||
w1[inside].astype(np.float32),
|
||||
w2[inside].astype(np.float32),
|
||||
)
|
||||
|
||||
|
||||
def bake_mask_and_albedo(shell, thr, mask_path, albedo_name, seed):
|
||||
"""One pass over the faces: bake the RGBA region mask AND the painted
|
||||
detail albedo (flat per-region luminance + stitch lines + woven noise).
|
||||
|
||||
Classification is per TEXEL (interpolated position + smoothed vertex
|
||||
normal), so zone boundaries are crisp at mask resolution instead of
|
||||
following the triangulation.
|
||||
"""
|
||||
W = H = base.MASK_SIZE
|
||||
mask = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask[:, :, 1] = 1.0 # green background = main body (bilinear-bleed safe)
|
||||
albedo = np.zeros((H, W, 4), dtype=np.float32)
|
||||
albedo[:, :, 0:3] = ALBEDO_LUMA["body"]
|
||||
albedo[:, :, 3] = 1.0
|
||||
label_map = np.full((H, W), LABEL_ID["body"], dtype=np.uint8)
|
||||
covered = np.zeros((H, W), dtype=bool)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.normal_update()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for bake")
|
||||
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv for loop in loops]
|
||||
pos = [loop.vert.co for loop in loops]
|
||||
nrm = [loop.vert.normal for loop in loops]
|
||||
for i in range(1, len(loops) - 1):
|
||||
tri = (0, i, i + 1)
|
||||
ys, xs, w0, w1, w2 = _tri_texels(uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H)
|
||||
if ys.size == 0:
|
||||
continue
|
||||
p = np.empty((ys.size, 3), dtype=np.float32)
|
||||
n = np.empty((ys.size, 3), dtype=np.float32)
|
||||
for axis in range(3):
|
||||
p[:, axis] = (w0 * pos[tri[0]][axis] + w1 * pos[tri[1]][axis]
|
||||
+ w2 * pos[tri[2]][axis])
|
||||
n[:, axis] = (w0 * nrm[tri[0]][axis] + w1 * nrm[tri[1]][axis]
|
||||
+ w2 * nrm[tri[2]][axis])
|
||||
n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9)
|
||||
lab = classify_texels(p, n, thr)
|
||||
label_map[ys, xs] = lab
|
||||
covered[ys, xs] = True
|
||||
mask[ys, xs] = LABEL_RGBA_ARR[lab]
|
||||
albedo[ys, xs, 0:3] = LABEL_LUMA_ARR[lab][:, None]
|
||||
total = max(int(covered.sum()), 1)
|
||||
tex_counts = np.bincount(label_map[covered], minlength=len(LABELS))
|
||||
log("region texels: " + " ".join(
|
||||
f"{LABELS[i]}={int(c)} ({100.0 * c / total:.1f}%)"
|
||||
for i, c in enumerate(tex_counts) if c > 0))
|
||||
|
||||
# Woven-feel noise over the fills, before stitch lines (lines stay crisp).
|
||||
rng = np.random.default_rng(seed)
|
||||
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
|
||||
albedo[:, :, 0:3] = np.clip(albedo[:, :, 0:3] + noise, 0.0, 1.0)
|
||||
|
||||
# Stitch lines: texel-space label transitions where BOTH texels belong to
|
||||
# rasterised geometry (skipping UV-island borders against background).
|
||||
# Buckle|belt stays seamless (same physical strap).
|
||||
lm = np.where(label_map == LABEL_ID["buckle"], LABEL_ID["belt"], label_map)
|
||||
edge = np.zeros((H, W), dtype=bool)
|
||||
dh = (lm[:, 1:] != lm[:, :-1]) & covered[:, 1:] & covered[:, :-1]
|
||||
edge[:, 1:] |= dh
|
||||
edge[:, :-1] |= dh
|
||||
dv = (lm[1:, :] != lm[:-1, :]) & covered[1:, :] & covered[:-1, :]
|
||||
edge[1:, :] |= dv
|
||||
edge[:-1, :] |= dv
|
||||
albedo[edge, 0:3] = STITCH_LUMA
|
||||
bm.free()
|
||||
log(f"stitch lines on {int(edge.sum())} boundary texels")
|
||||
|
||||
# Floor the alpha channel at 2/255: Godot's default texture import runs
|
||||
# process/fix_alpha_border, which rewrites the RGB of fully-transparent
|
||||
# texels bordering opaque ones — that would smear the shoulder-mark island
|
||||
# edges into the surrounding body-green weights. With no alpha-0 texels the
|
||||
# pass is a no-op; the 0.8% tint_3 weight it adds everywhere is invisible.
|
||||
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
|
||||
|
||||
img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True)
|
||||
# The mask is channel-packed DATA (R/G/B/A region weights), not imagery
|
||||
# with transparency: without this, Blender's straight-alpha PNG save
|
||||
# zeroes the A channel (verified: shoulder-mark texels came back A=0).
|
||||
img_mask.alpha_mode = 'CHANNEL_PACKED'
|
||||
img_mask.pixels.foreach_set(mask.reshape(-1))
|
||||
img_mask.update()
|
||||
img_mask.filepath_raw = mask_path
|
||||
img_mask.file_format = 'PNG'
|
||||
img_mask.save()
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
|
||||
img_albedo = bpy.data.images.new(f"albedo_{albedo_name}", W, H, alpha=False)
|
||||
img_albedo.pixels.foreach_set(albedo.reshape(-1))
|
||||
img_albedo.update()
|
||||
return img_albedo
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_coverall(body_dir, out_dir, body, offset, seed):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
thr = derive_coverall_thresholds(armature)
|
||||
limb_cuts(shell, thr)
|
||||
base.offset_outward(shell, offset)
|
||||
|
||||
# Author UV2 + bake mask/albedo BEFORE solidify: the inner shell that
|
||||
# solidify adds duplicates every face with the SAME atlas UVs but a
|
||||
# flipped normal — the normal-gated rules (shoulder marks, patches) would
|
||||
# classify those copies as body and overwrite the very texels the outer
|
||||
# faces just wrote. Pre-solidify there is exactly one face per texel.
|
||||
base.author_logo_uv(shell, thr) # UV2 (inner shell inherits it, harmless)
|
||||
albedo_img = bake_mask_and_albedo(
|
||||
shell, thr, os.path.join(out_dir, f"{body}_mask.png"), body, seed)
|
||||
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
# Save the albedo under the SHARED name before export: the glTF exporter
|
||||
# names the embedded texture after the image filepath basename, and the
|
||||
# Godot GLB import extracts it as <glb>_<texname>.png — with the shared
|
||||
# name that lands on the tshirt-convention <body>_base_albedo.png (a
|
||||
# <body>-specific filepath here would yield <body>_<body>_base_albedo.png).
|
||||
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
# Keep a deterministic authored per-body sidecar as well.
|
||||
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
||||
os.path.join(out_dir, f"{body}_base_albedo.png"))
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] [--offset M]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = base.PER_BODY_OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"coverall per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
|
||||
|
||||
results = []
|
||||
for i, body in enumerate(bodies):
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_coverall(body_dir, out_dir, body, offset, seed=1089 + i)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Runtime fallback: reference_mask.png + base_albedo.png mirror average_m.
|
||||
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
|
||||
# base_albedo.png currently holds the LAST body's albedo; restore the
|
||||
# reference body's copy so the shared sidecar is deterministic.
|
||||
ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png")
|
||||
if os.path.isfile(ref_albedo):
|
||||
shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {base.REFERENCE_BODY}_base_albedo.png -> base_albedo.png")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,887 @@
|
||||
"""
|
||||
blender_author_offset_shell.py (T-1089, route (c) offset-shell authoring)
|
||||
|
||||
Derive a garment SHELL from our own body segment meshes — the "create-stuff-
|
||||
yourself" authoring pipeline that owes nothing to any vendor pack. Because the
|
||||
shell IS our body topology, bone weights are inherited by construction (no
|
||||
Surface-Deform, no Data-Transfer, no re-rig): every vertex keeps the 65-bone
|
||||
vertex groups it had as skin.
|
||||
|
||||
Pipeline (t-shirt reference on average_m):
|
||||
1. Import the body segments the garment covers (torso + torso_upper + upper
|
||||
arms), keep only the skinned body meshes (Icosphere debris filtered out).
|
||||
2. Join into one mesh under a single armature; merge vertex groups by name.
|
||||
3. Bone-plane SLEEVE cut — trim the upper-arm tube to short-sleeve length via
|
||||
a coordinate threshold derived from the upperarm bone axis (robust; no
|
||||
boundary-loop classification, which the segment tool warns is fragile).
|
||||
Neckline + hem come free as the natural segment boundaries.
|
||||
4. Offset the surface outward along vertex normals (~12 mm standoff from skin).
|
||||
5. Solidify (use_rim=True) — gives the cloth real thickness and caps the cut
|
||||
rims (sleeve openings) into hems.
|
||||
6. Assign ONE flat modern-fabric material (drops all skin textures). Style pin:
|
||||
neutral heather tone, no trim, no fantasy anything.
|
||||
7. Bake a UV0-aligned RGBA REGION MASK per-face: collar band -> R, main body
|
||||
-> G, sleeve trim -> B (channel-routed 4-tint shader input, G3).
|
||||
8. Author a 2nd UV channel (TEXCOORD_1) projecting the front chest into [0,1]
|
||||
for the logo decal (G4); everything else parks outside the box.
|
||||
9. Export the reference GLB (export_skins=True) + write reference_mask.png and
|
||||
base_albedo.png sidecars.
|
||||
|
||||
Two modes (T-1089):
|
||||
|
||||
SINGLE-REFERENCE (default) — author on one body (average_m); G1
|
||||
(blender_batch_fit_skinned.py) SD-fits it to the other body types. Right for
|
||||
derived/hand-authored garments that share one UV layout + one mask.
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_offset_shell.py -- \
|
||||
<bodies_dir>/average_m <out_dir> [--offset 0.020] [--sleeve-frac 0.40]
|
||||
|
||||
Writes:
|
||||
<out_dir>/average_m.glb reference garment (skinned)
|
||||
<out_dir>/reference_mask.png RGBA region mask (UV0-aligned)
|
||||
<out_dir>/base_albedo.png flat fabric albedo (also embedded in the GLB)
|
||||
|
||||
PER-BODY (--per-body) — author the shell from EACH body's own segment meshes.
|
||||
QA evidence (Q-060): single-reference SD-fit of offset-shells degrades with
|
||||
girth divergence (muscular_m 859px worst clip at 24 mm standoff). Authoring
|
||||
per body gives guaranteed standoff + exact weights by construction, and lets
|
||||
the offset drop back to the ~12 mm ideal. Cut/mask parameters are derived
|
||||
from each body's OWN bone landmarks using the same proportional ratios
|
||||
(anchored to reproduce the hand-calibrated average_m constants exactly), so
|
||||
regions stay consistent across bodies.
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_offset_shell.py -- \
|
||||
<bodies_dir> <out_dir> --per-body \
|
||||
[--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40]
|
||||
|
||||
Writes per body:
|
||||
<out_dir>/<body>.glb skinned garment authored on that body
|
||||
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0 layout)
|
||||
Plus:
|
||||
<out_dir>/base_albedo.png shared flat fabric albedo (deterministic)
|
||||
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
|
||||
|
||||
The runtime compositor (character_visual.gd) prefers <body>_mask.png and
|
||||
falls back to reference_mask.png for SD-fit garments.
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Segments a t-shirt covers. Order matters only for join-active choice.
|
||||
COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r"]
|
||||
|
||||
OFFSET_M = 0.020 # single-reference standoff along vertex normals; larger
|
||||
# than the 10-14 mm ideal on the reference body buys
|
||||
# clearance for bigger bodies under Surface-Deform
|
||||
# batch-fit (Q-060) — the muscular/female torso otherwise
|
||||
# pokes through.
|
||||
PER_BODY_OFFSET_M = 0.012 # per-body standoff — each body's own surface guarantees
|
||||
# clearance by construction, so the ideal applies.
|
||||
CLOTH_THICKNESS_M = 0.004 # Solidify thickness after offset
|
||||
SLEEVE_FRAC = 0.40 # fraction of upper-arm length kept (short sleeve)
|
||||
MASK_SIZE = 512
|
||||
ALBEDO_SIZE = 512
|
||||
|
||||
# All shipping body types (matches blender_batch_fit_skinned.py / the runtime).
|
||||
BODY_TYPES = [
|
||||
"average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f",
|
||||
"heavy_m", "heavy_f", "teen_m", "teen_f", "child",
|
||||
]
|
||||
REFERENCE_BODY = "average_m"
|
||||
|
||||
# Flat modern-fabric base tone (linear-ish sRGB), neutral heather grey.
|
||||
FABRIC_RGB = (0.60, 0.61, 0.63)
|
||||
FABRIC_NOISE = 0.03 # +/- albedo jitter for a subtle woven feel
|
||||
|
||||
# Chest logo box in body-local metres (X width, Z height).
|
||||
# FRONT AXIS: these Quaternius bodies face -Y in Blender (verified empirically —
|
||||
# a +Y test projection landed on the character's back). So "front" = -Y.
|
||||
FRONT_Y_SIGN = -1.0
|
||||
CHEST_X = (-0.12, 0.12)
|
||||
CHEST_Z = (1.16, 1.44)
|
||||
CHEST_FRONT_Y = 0.015 # face centre must be on the front side by at least this
|
||||
CHEST_NORMAL_Y = 0.20 # face normal must point forward by at least this much
|
||||
|
||||
# Region-mask classification (body-local, Z up).
|
||||
COLLAR_Z_MIN = 1.49 # faces above this AND near centre -> collar band (R)
|
||||
COLLAR_X_ABS = 0.11 # collar band stays near the neck, not the shoulders
|
||||
SLEEVE_X_ABS = 0.20 # faces with |center X| beyond this -> sleeve cap (B)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body threshold derivation (T-1089 per-body shell mode)
|
||||
#
|
||||
# The absolute constants above were hand-calibrated on average_m. The ratios
|
||||
# below re-express every one of them against average_m's bone landmarks
|
||||
# (shoulder = upperarm head |x| 0.1919, neck_01 head z 1.5205 / length 0.0793,
|
||||
# spine_01 head z 1.072) so any body derives the SAME proportional cut/mask
|
||||
# parameters from its own armature. On average_m the derivation reproduces
|
||||
# the legacy constants exactly; the 11 bodies share one 65-bone rig, so the
|
||||
# landmarks exist everywhere.
|
||||
# --------------------------------------------------------------------------
|
||||
_REF_SHOULDER_X = 0.1919
|
||||
_REF_NECK_Z = 1.5205
|
||||
_REF_NECK_LEN = 0.0793
|
||||
_REF_SPINE_LO_Z = 1.072
|
||||
|
||||
SLEEVE_X_FRAC = SLEEVE_X_ABS / _REF_SHOULDER_X # of shoulder |x|
|
||||
COLLAR_X_FRAC = COLLAR_X_ABS / _REF_SHOULDER_X # of shoulder |x|
|
||||
COLLAR_DROP_FRAC = (_REF_NECK_Z - COLLAR_Z_MIN) / _REF_NECK_LEN # below neck head
|
||||
CHEST_X_FRAC = CHEST_X[1] / _REF_SHOULDER_X # of shoulder |x|
|
||||
_REF_SPINE_SPAN = _REF_NECK_Z - _REF_SPINE_LO_Z
|
||||
CHEST_Z_LO_FRAC = (CHEST_Z[0] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN
|
||||
CHEST_Z_HI_FRAC = (CHEST_Z[1] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[offset-shell] {msg}")
|
||||
|
||||
|
||||
def derive_thresholds(armature):
|
||||
"""Derive cut/mask thresholds from this body's bone landmarks.
|
||||
|
||||
Returns a dict {sleeve_x_abs, collar_z_min, collar_x_abs, chest_x, chest_z}.
|
||||
Falls back to the legacy average_m constants when landmarks are missing.
|
||||
"""
|
||||
bones = armature.data.bones
|
||||
ua_l = bones.get("upperarm_l")
|
||||
ua_r = bones.get("upperarm_r")
|
||||
neck = bones.get("neck_01")
|
||||
spine01 = bones.get("spine_01")
|
||||
if not all([ua_l, ua_r, neck, spine01]):
|
||||
log("WARNING: landmark bones missing — using legacy average_m thresholds")
|
||||
return {
|
||||
"sleeve_x_abs": SLEEVE_X_ABS,
|
||||
"collar_z_min": COLLAR_Z_MIN,
|
||||
"collar_x_abs": COLLAR_X_ABS,
|
||||
"chest_x": CHEST_X,
|
||||
"chest_z": CHEST_Z,
|
||||
}
|
||||
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
neck_z = neck.head_local.z
|
||||
neck_len = neck.tail_local.z - neck.head_local.z
|
||||
spine_lo = spine01.head_local.z
|
||||
spine_span = neck_z - spine_lo
|
||||
thr = {
|
||||
"sleeve_x_abs": shoulder_x * SLEEVE_X_FRAC,
|
||||
"collar_z_min": neck_z - COLLAR_DROP_FRAC * neck_len,
|
||||
"collar_x_abs": shoulder_x * COLLAR_X_FRAC,
|
||||
"chest_x": (-shoulder_x * CHEST_X_FRAC, shoulder_x * CHEST_X_FRAC),
|
||||
"chest_z": (spine_lo + CHEST_Z_LO_FRAC * spine_span,
|
||||
spine_lo + CHEST_Z_HI_FRAC * spine_span),
|
||||
}
|
||||
log(f"thresholds: sleeve |x|>={thr['sleeve_x_abs']:.3f} "
|
||||
f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_x_abs']:.3f} "
|
||||
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
|
||||
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
|
||||
return thr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Scene helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
bpy.ops.outliner.orphans_purge(do_recursive=True)
|
||||
|
||||
|
||||
def import_glb(path):
|
||||
before = set(bpy.context.scene.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
return [o for o in bpy.context.scene.objects if o not in before]
|
||||
|
||||
|
||||
def is_body_mesh(obj):
|
||||
"""A real skinned body segment mesh — not Icosphere debris."""
|
||||
if obj.type != 'MESH':
|
||||
return False
|
||||
if obj.name.startswith("Icosphere"):
|
||||
return False
|
||||
if len(obj.vertex_groups) == 0:
|
||||
return False
|
||||
if len(obj.data.vertices) < 50:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Build the joined shell base
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def build_covered_mesh(body_dir):
|
||||
"""Import covered segments, keep skinned meshes, join to one mesh + armature."""
|
||||
body_meshes = []
|
||||
armature = None
|
||||
|
||||
for seg in COVERED_SEGMENTS:
|
||||
path = os.path.join(body_dir, f"{seg}.glb")
|
||||
if not os.path.isfile(path):
|
||||
log(f"WARNING: missing segment {path} — skipping")
|
||||
continue
|
||||
objs = import_glb(path)
|
||||
for o in objs:
|
||||
if o.type == 'ARMATURE' and armature is None:
|
||||
armature = o
|
||||
elif o.type == 'ARMATURE':
|
||||
# drop extra armature copies (identical rest pose)
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
elif is_body_mesh(o):
|
||||
body_meshes.append(o)
|
||||
else:
|
||||
# Icosphere / debris
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
if not body_meshes:
|
||||
raise RuntimeError("no skinned body meshes imported for covered segments")
|
||||
if armature is None:
|
||||
raise RuntimeError("no armature found in covered segments")
|
||||
|
||||
# Join meshes (vertex groups merge by name across segments).
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for m in body_meshes:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = body_meshes[0]
|
||||
bpy.ops.object.join()
|
||||
shell = bpy.context.active_object
|
||||
shell.name = "garment_shell"
|
||||
|
||||
# Re-point the armature modifier at the surviving armature; re-parent.
|
||||
for mod in list(shell.modifiers):
|
||||
if mod.type == 'ARMATURE':
|
||||
mod.object = armature
|
||||
shell.parent = armature
|
||||
shell.matrix_parent_inverse = armature.matrix_world.inverted()
|
||||
|
||||
log(f"joined shell: {len(shell.data.vertices)} verts, "
|
||||
f"{len(shell.data.polygons)} faces, {len(shell.vertex_groups)} vgroups")
|
||||
return shell, armature
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bone-plane sleeve cut
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def sleeve_cut(shell, armature):
|
||||
"""Delete sleeve-tip verts beyond the short-sleeve plane on each upper arm.
|
||||
|
||||
The upper arm runs along +/-X (shoulder head -> elbow tail). We keep the
|
||||
fraction SLEEVE_FRAC of that length from the shoulder and delete the rest.
|
||||
Torso verts stay (|X| < shoulder head), so a single coordinate threshold is
|
||||
safe and needs no per-vertex weight test.
|
||||
"""
|
||||
bones = armature.data.bones
|
||||
cut_planes = [] # (axis_sign, threshold_x)
|
||||
for bone_name, sign in [("upperarm_l", +1), ("upperarm_r", -1)]:
|
||||
b = bones.get(bone_name)
|
||||
if b is None:
|
||||
log(f"WARNING: bone {bone_name} missing — sleeve not cut on that side")
|
||||
continue
|
||||
head_x = b.head_local.x
|
||||
tail_x = b.tail_local.x
|
||||
thr = head_x + SLEEVE_FRAC * (tail_x - head_x)
|
||||
cut_planes.append((sign, thr))
|
||||
log(f"sleeve cut {bone_name}: keep |x| up to {thr:.3f} "
|
||||
f"(shoulder {head_x:.3f} -> elbow {tail_x:.3f})")
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = []
|
||||
for v in bm.verts:
|
||||
for sign, thr in cut_planes:
|
||||
if sign > 0 and v.co.x > thr:
|
||||
to_delete.append(v)
|
||||
break
|
||||
if sign < 0 and v.co.x < thr:
|
||||
to_delete.append(v)
|
||||
break
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"sleeve cut removed {len(to_delete)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Outward offset + solidify
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def offset_outward(shell, offset):
|
||||
"""Push every vertex outward along its (smoothed) normal by `offset` m."""
|
||||
me = shell.data
|
||||
me.calc_normals_split() if hasattr(me, "calc_normals_split") else None
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.normal_update()
|
||||
for v in bm.verts:
|
||||
v.co += v.normal * offset
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"offset surface outward by {offset*1000:.0f} mm along normals")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Convex toe box (T-1089 footwear fix — shared by sneakers/shoes/boots)
|
||||
#
|
||||
# Closed shoes have a smooth rigid TOE BOX: a convex rounded cap the toes sit
|
||||
# INSIDE, not a shell that wraps each toe. The earlier per-script approach
|
||||
# (Laplacian smooth the toes, then push verts back out to the ORIGINAL skin
|
||||
# surface) re-imprinted the individual toes — the skin-conforming clamp
|
||||
# followed each toe bump, so bumps/pokes survived. This routine instead
|
||||
# forces every toe cross-section onto one analytic half-ellipse dome that
|
||||
# CIRCUMSCRIBES the toes (guaranteed outside the skin, so no poke, and no
|
||||
# per-toe detail survives), extends the nose forward past the longest toe,
|
||||
# and rebinds the whole box UNIFORMLY to the ball bone so it flexes rigidly
|
||||
# at the ball joint with no per-vertex toe-weight ripple under animation.
|
||||
#
|
||||
# Applied PRE-offset: the mold encloses the skin toes by construction, then
|
||||
# offset_outward adds the standoff uniformly over a smooth surface. No skin
|
||||
# clamp is needed (or wanted) in the toe zone afterward.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _smoothstep(t):
|
||||
t = min(max(t, 0.0), 1.0)
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
|
||||
|
||||
def foot_ball_u(armature):
|
||||
"""Forward coord (u = y*FRONT_Y_SIGN) of the ball joint (toe-box hinge).
|
||||
|
||||
Bodies face -Y so toes point -Y; u increases toward the toes. ball_l/ball_r
|
||||
share the same forward head coord (feet are x-mirror symmetric)."""
|
||||
ball = armature.data.bones.get("ball_l")
|
||||
if ball is None:
|
||||
return None
|
||||
return ball.head_local.y * FRONT_Y_SIGN
|
||||
|
||||
|
||||
def _interp(x, xp, fp):
|
||||
"""Minimal linear interp with flat ends (np.interp semantics, no import)."""
|
||||
if x <= xp[0]:
|
||||
return fp[0]
|
||||
if x >= xp[-1]:
|
||||
return fp[-1]
|
||||
for i in range(1, len(xp)):
|
||||
if x < xp[i]:
|
||||
t = (x - xp[i - 1]) / max(xp[i] - xp[i - 1], 1e-9)
|
||||
return fp[i - 1] + t * (fp[i] - fp[i - 1])
|
||||
return fp[-1]
|
||||
|
||||
|
||||
def convex_toe_box(shell, armature, *, extension, width_margin, height_clear,
|
||||
nbins=10, feather_m=0.020, bottom_band_m=0.0015,
|
||||
nose_frac=0.40, smooth_iters=7, smooth_factor=0.6,
|
||||
uniform_ball_weights=True):
|
||||
"""Reshape the forefoot into a smooth convex toe box (per foot side).
|
||||
|
||||
Each cross-section forward of the ball joint is forced onto ONE smooth
|
||||
ellipse that circumscribes that slice's toe verts — every individual-toe
|
||||
bump/crevice is erased and the shell sits OUTSIDE the skin (the ellipse is
|
||||
the slice's own enclosing ellipse + a margin, so projecting only pushes
|
||||
verts outward). Sizes are measured per u-slice (never a single collapsing
|
||||
quadric, which over-inflates), so the box follows the foot's natural taper
|
||||
while reading as one rigid cap. The frontmost `nose_frac` of the toe length
|
||||
is pushed forward up to `extension` past the longest toe. The whole cap is
|
||||
rebound uniformly to the ball bone so it flexes rigidly at the ball joint
|
||||
with no per-vertex toe-weight ripple.
|
||||
|
||||
extension forward nose extension past the longest toe (m).
|
||||
width_margin half-width padding added around each slice (m).
|
||||
height_clear vertical headroom added above the toes (m) — flex room.
|
||||
nbins number of u-slices sized independently along the toe length.
|
||||
feather_m blend band behind the ball over which effect + rebind ramp.
|
||||
bottom_band_m underside band left for the sole routine (dome does top+sides).
|
||||
nose_frac fraction of the toe length (from the tip back) that is pushed
|
||||
forward to form the extended rounded nose.
|
||||
"""
|
||||
ball_u = foot_ball_u(armature)
|
||||
if ball_u is None:
|
||||
log("WARNING: ball_l missing — convex toe box skipped")
|
||||
return
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.normal_update() # pre-reshape normals gate the underside (sole) verts
|
||||
dl = bm.verts.layers.deform.verify()
|
||||
gi = {g.name: g.index for g in shell.vertex_groups}
|
||||
ball_gi = {1: gi.get("ball_l"), -1: gi.get("ball_r")}
|
||||
|
||||
verts = list(bm.verts)
|
||||
feather_u0 = ball_u - feather_m
|
||||
total_reshaped = 0
|
||||
for side in (1, -1):
|
||||
sverts = [v for v in verts if (v.co.x * side) > 0.0]
|
||||
toe = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > ball_u]
|
||||
if len(toe) < 6:
|
||||
continue
|
||||
cx = sum(v.co.x for v in toe) / len(toe)
|
||||
base_z = min(v.co.z for v in sverts) # per-side sole level
|
||||
u_tip = max(v.co.y * FRONT_Y_SIGN for v in toe)
|
||||
span = max(u_tip - ball_u, 1e-6)
|
||||
|
||||
# --- per-slice circumscribing ellipse (top + side verts only) --------
|
||||
centers = [ball_u + span * (i + 0.5) / nbins for i in range(nbins)]
|
||||
Barr = [width_margin] * nbins
|
||||
Harr = [height_clear] * nbins
|
||||
bin_verts = [[] for _ in range(nbins)]
|
||||
for v in toe:
|
||||
if (v.co.z - base_z) <= bottom_band_m:
|
||||
continue # underside -> sole routine
|
||||
i = int((v.co.y * FRONT_Y_SIGN - ball_u) / span * nbins)
|
||||
i = min(max(i, 0), nbins - 1)
|
||||
bin_verts[i].append(v)
|
||||
for i in range(nbins):
|
||||
bv = bin_verts[i]
|
||||
if not bv:
|
||||
continue
|
||||
b0 = max(abs(v.co.x - cx) for v in bv) + width_margin
|
||||
h0 = max(v.co.z - base_z for v in bv) + height_clear
|
||||
# circumscribe: scale the (b0,h0) ellipse until it holds every vert
|
||||
kmax = 1.0
|
||||
for v in bv:
|
||||
rr = (((v.co.x - cx) / b0) ** 2
|
||||
+ ((v.co.z - base_z) / h0) ** 2) ** 0.5
|
||||
kmax = max(kmax, rr)
|
||||
Barr[i] = b0 * kmax
|
||||
Harr[i] = h0 * kmax
|
||||
# fill empty bins by carrying the last known size forward/back
|
||||
for i in range(1, nbins):
|
||||
if bin_verts[i] == [] or Barr[i] == width_margin:
|
||||
Barr[i], Harr[i] = Barr[i - 1], Harr[i - 1]
|
||||
# one along-length smoothing pass (keeps the cap from stepping)
|
||||
Bs = list(Barr)
|
||||
Hs = list(Harr)
|
||||
for i in range(1, nbins - 1):
|
||||
Bs[i] = 0.25 * Barr[i - 1] + 0.5 * Barr[i] + 0.25 * Barr[i + 1]
|
||||
Hs[i] = 0.25 * Harr[i - 1] + 0.5 * Harr[i] + 0.25 * Harr[i + 1]
|
||||
|
||||
nose_start = u_tip - nose_frac * span
|
||||
work = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > feather_u0]
|
||||
|
||||
# Capture the underside gate from the SKIN (pre-smooth) normals so it
|
||||
# matches how the per-style sole routine classifies its verts (roughly
|
||||
# normal.z < -0.5). Verts the sole owns are excluded from the cap, so
|
||||
# the cap never fights the sole flatten (which caused underside tears).
|
||||
gate = {}
|
||||
for v in work:
|
||||
gate[v.index] = _smoothstep((v.normal.z + 0.5) / 0.25) # -0.5->0
|
||||
|
||||
# --- fill the between-toe notches (Laplacian) BEFORE projecting -------
|
||||
# The individual-toe crevices are deep valleys; in-place ellipse
|
||||
# projection alone leaves their walls. Smoothing melts the valleys into
|
||||
# one volume (like the old pipeline) — but the ellipse SIZES above were
|
||||
# measured from the ORIGINAL toe, so the projection below pushes the
|
||||
# smoothed (shrunk) surface back OUT onto a cap that still encloses the
|
||||
# real skin. The uniform ball rebind fixes the flex ripple that made
|
||||
# the old pipeline keep its smoothing timid.
|
||||
if smooth_iters > 0:
|
||||
toe_all = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > ball_u]
|
||||
for _ in range(smooth_iters):
|
||||
bmesh.ops.smooth_vert(bm, verts=toe_all, factor=smooth_factor,
|
||||
use_axis_x=True, use_axis_y=True,
|
||||
use_axis_z=True)
|
||||
|
||||
n_side = 0
|
||||
for v in work:
|
||||
u = v.co.y * FRONT_Y_SIGN
|
||||
f = _smoothstep((u - feather_u0) / max(ball_u - feather_u0, 1e-6))
|
||||
# ellipse size at this u (from the pre-stretch position)
|
||||
B = _interp(u, centers, Bs)
|
||||
H = _interp(u, centers, Hs)
|
||||
hpos = v.co.z - base_z
|
||||
wn = gate[v.index]
|
||||
wh = 1.0 if hpos > bottom_band_m else 0.0
|
||||
w = f * wn * wh
|
||||
dx = v.co.x - cx
|
||||
hh = max(hpos, 0.0)
|
||||
rr = ((dx / B) ** 2 + (hh / H) ** 2) ** 0.5
|
||||
if w > 1e-6 and rr > 1e-6:
|
||||
scale = min(max(1.0 / rr, 0.5), 2.5)
|
||||
tx = cx + dx * scale
|
||||
tz = base_z + hh * scale
|
||||
v.co.x += (tx - v.co.x) * w
|
||||
v.co.z += (tz - v.co.z) * w
|
||||
n_side += 1
|
||||
# forward nose push (feathered from nose_start to the tip)
|
||||
if u > nose_start:
|
||||
t = _smoothstep((u - nose_start) / max(u_tip - nose_start, 1e-6))
|
||||
v.co.y += -extension * t * FRONT_Y_SIGN * f
|
||||
|
||||
# Uniform ball rebinding (feathered by the length feather f).
|
||||
bi = ball_gi[side]
|
||||
if uniform_ball_weights and bi is not None:
|
||||
for v in work:
|
||||
u = v.co.y * FRONT_Y_SIGN
|
||||
f = _smoothstep((u - feather_u0) / max(ball_u - feather_u0, 1e-6))
|
||||
if f <= 1e-6:
|
||||
continue
|
||||
dv = v[dl]
|
||||
for gidx in list(dv.keys()):
|
||||
dv[gidx] = dv[gidx] * (1.0 - f)
|
||||
cur = dv[bi] if bi in dv else 0.0
|
||||
dv[bi] = cur + f
|
||||
tot = sum(dv[g] for g in dv.keys())
|
||||
if tot > 1e-8:
|
||||
for gidx in list(dv.keys()):
|
||||
dv[gidx] = dv[gidx] / tot
|
||||
total_reshaped += n_side
|
||||
log(f"toe box side {'L' if side > 0 else 'R'}: {len(toe)} toe verts, "
|
||||
f"B={min(Bs) * 1000:.0f}-{max(Bs) * 1000:.0f}mm "
|
||||
f"H={min(Hs) * 1000:.0f}-{max(Hs) * 1000:.0f}mm "
|
||||
f"cap +{extension * 1000:.0f}mm, reshaped {n_side}")
|
||||
|
||||
bm.normal_update()
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"convex toe box: reshaped {total_reshaped} verts "
|
||||
f"(headroom {height_clear * 1000:.0f}mm, nose +{extension * 1000:.0f}mm)")
|
||||
|
||||
|
||||
def solidify(shell, thickness):
|
||||
"""Solidify with use_rim to give cloth thickness and cap the cut rims."""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
shell.select_set(True)
|
||||
bpy.context.view_layer.objects.active = shell
|
||||
# consistent outward normals first
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.normals_make_consistent(inside=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
sol = shell.modifiers.new(name="Solidify", type='SOLIDIFY')
|
||||
sol.thickness = thickness
|
||||
sol.offset = 1.0 # grow outward only
|
||||
sol.use_rim = True # cap open boundaries (sleeve/neck/hem)
|
||||
sol.use_rim_only = False
|
||||
bpy.ops.object.modifier_apply(modifier=sol.name)
|
||||
log(f"solidified: {thickness*1000:.0f} mm, use_rim; "
|
||||
f"{len(shell.data.vertices)} verts")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Material (flat fabric albedo)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def make_base_albedo_image(seed=1089):
|
||||
img = bpy.data.images.new("garment_base_albedo", ALBEDO_SIZE, ALBEDO_SIZE, alpha=False)
|
||||
rng = np.random.default_rng(seed)
|
||||
base = np.array(FABRIC_RGB, dtype=np.float32)
|
||||
noise = (rng.random((ALBEDO_SIZE * ALBEDO_SIZE, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE
|
||||
rgb = np.clip(base[None, :] + noise, 0.0, 1.0)
|
||||
rgba = np.concatenate([rgb, np.ones((ALBEDO_SIZE * ALBEDO_SIZE, 1), dtype=np.float32)], axis=1)
|
||||
img.pixels.foreach_set(rgba.reshape(-1))
|
||||
img.update()
|
||||
return img
|
||||
|
||||
|
||||
def assign_fabric_material(shell, albedo_img):
|
||||
shell.data.materials.clear()
|
||||
mat = bpy.data.materials.new("garment_fabric")
|
||||
mat.use_nodes = True
|
||||
nt = mat.node_tree
|
||||
bsdf = nt.nodes.get("Principled BSDF")
|
||||
tex = nt.nodes.new("ShaderNodeTexImage")
|
||||
tex.image = albedo_img
|
||||
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
if "Roughness" in bsdf.inputs:
|
||||
bsdf.inputs["Roughness"].default_value = 0.9
|
||||
shell.data.materials.append(mat)
|
||||
log("assigned flat fabric material (skin textures dropped)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Region mask bake (UV0-aligned, per-face rasterization)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _classify_region(center, thr):
|
||||
"""Return an RGBA region colour for a face centre (body-local coords)."""
|
||||
x, z = center.x, center.z
|
||||
if abs(x) >= thr["sleeve_x_abs"]:
|
||||
return (0.0, 0.0, 1.0, 0.0) # sleeve caps -> B (tint[2])
|
||||
if z >= thr["collar_z_min"] and abs(x) < thr["collar_x_abs"]:
|
||||
return (1.0, 0.0, 0.0, 0.0) # neck collar band -> R (tint[0])
|
||||
return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1])
|
||||
|
||||
|
||||
def _tris_from_face(face, uv_layer):
|
||||
"""Fan-triangulate a bmesh face into (uv, uv, uv) tuples in [0,1] space."""
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
tris = []
|
||||
for i in range(1, len(uvs) - 1):
|
||||
tris.append((uvs[0], uvs[i], uvs[i + 1]))
|
||||
return tris
|
||||
|
||||
|
||||
def bake_region_mask(shell, out_path, thr):
|
||||
"""Rasterize each face's UV0 triangle with its region colour into MASK_SIZE^2.
|
||||
|
||||
Background initialised to main-body green so bilinear bleed at island edges
|
||||
never lands on an untinted (all-zero) texel.
|
||||
"""
|
||||
W = H = MASK_SIZE
|
||||
buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
buf[:, :, 1] = 1.0 # green background = main body
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for region mask bake")
|
||||
|
||||
region_counts = {"collar": 0, "body": 0, "sleeve": 0}
|
||||
for face in bm.faces:
|
||||
color = _classify_region(face.calc_center_median(), thr)
|
||||
if color[0] > 0.5:
|
||||
region_counts["collar"] += 1
|
||||
elif color[2] > 0.5:
|
||||
region_counts["sleeve"] += 1
|
||||
else:
|
||||
region_counts["body"] += 1
|
||||
for a, b, c in _tris_from_face(face, uv_layer):
|
||||
_raster_tri(buf, a, b, c, color, W, H)
|
||||
bm.free()
|
||||
total = max(sum(region_counts.values()), 1)
|
||||
log("region faces: " + " ".join(
|
||||
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in region_counts.items()))
|
||||
|
||||
# Blender image is bottom-up; buf row 0 is V=0 (bottom) already since we
|
||||
# rasterise with row = v*(H-1). Save via Blender to match the texture pipe.
|
||||
img = bpy.data.images.new("garment_region_mask", W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = out_path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
log(f"baked region mask -> {out_path}")
|
||||
|
||||
|
||||
def _raster_tri(buf, a, b, c, color, W, H):
|
||||
"""Barycentric fill of a UV triangle into buf (V=0 at row 0 = bottom)."""
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
px = xs + 0.5
|
||||
py = ys + 0.5
|
||||
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
|
||||
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
region = buf[miny:maxy + 1, minx:maxx + 1, :]
|
||||
col = np.array(color, dtype=np.float32)
|
||||
region[inside] = col
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Logo UV2 chest channel
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_logo_uv(shell, thr):
|
||||
"""Create a 2nd UV layer projecting front chest faces into [0,1]; park the
|
||||
rest outside the box (shader guards uv2 in [0,1])."""
|
||||
me = shell.data
|
||||
# Keep exactly two UV layers: primary (albedo/mask) + logo. Remove extras.
|
||||
while len(me.uv_layers) > 1:
|
||||
me.uv_layers.remove(me.uv_layers[-1])
|
||||
logo_uv = me.uv_layers.new(name="logo_uv")
|
||||
me.uv_layers.active = me.uv_layers[0] # keep albedo layer active for mask bake safety
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.normal_update()
|
||||
uvl = bm.loops.layers.uv.get("logo_uv")
|
||||
|
||||
x0, x1 = thr["chest_x"]
|
||||
z0, z1 = thr["chest_z"]
|
||||
placed = 0
|
||||
for face in bm.faces:
|
||||
center = face.calc_center_median()
|
||||
on_chest = (
|
||||
center.y * FRONT_Y_SIGN > CHEST_FRONT_Y
|
||||
and x0 <= center.x <= x1
|
||||
and z0 <= center.z <= z1
|
||||
and face.normal.y * FRONT_Y_SIGN > CHEST_NORMAL_Y
|
||||
)
|
||||
for loop in face.loops:
|
||||
if on_chest:
|
||||
co = loop.vert.co
|
||||
# Empirically calibrated for the -Y front so the wordmark reads
|
||||
# upright and left-to-right from the camera (see report: a plain
|
||||
# projection came out 180deg-rotated on this rig).
|
||||
u = (co.x - x0) / (x1 - x0)
|
||||
v = (co.z - z0) / (z1 - z0)
|
||||
loop[uvl].uv = (min(max(u, 0.0), 1.0), min(max(v, 0.0), 1.0))
|
||||
else:
|
||||
loop[uvl].uv = (2.0, 2.0) # parked outside box
|
||||
if on_chest:
|
||||
placed += 1
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"logo UV2 authored on {placed} chest faces")
|
||||
if placed == 0:
|
||||
log("WARNING: no chest faces matched — check CHEST_* box / front axis")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Export
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def export_reference(shell, armature, out_path):
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
shell.select_set(True)
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=out_path,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False, # keep Armature modifier for skinning
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
export_yup=True,
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_materials='EXPORT',
|
||||
export_image_format='AUTO',
|
||||
)
|
||||
size_kb = os.path.getsize(out_path) // 1024
|
||||
log(f"exported reference -> {out_path} ({size_kb} KB)")
|
||||
|
||||
|
||||
def save_albedo_sidecar(albedo_img, out_path):
|
||||
albedo_img.filepath_raw = out_path
|
||||
albedo_img.file_format = 'PNG'
|
||||
albedo_img.save()
|
||||
log(f"saved base albedo -> {out_path}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Entry
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_shell(body_dir, out_dir, glb_name, mask_name, offset):
|
||||
"""Author one offset-shell garment from `body_dir`'s segments.
|
||||
|
||||
Shared by both modes; thresholds derive from the body's own armature so
|
||||
the same proportional cut/mask parameters apply on every body.
|
||||
"""
|
||||
clear_scene()
|
||||
shell, armature = build_covered_mesh(body_dir)
|
||||
thr = derive_thresholds(armature)
|
||||
sleeve_cut(shell, armature)
|
||||
offset_outward(shell, offset)
|
||||
solidify(shell, CLOTH_THICKNESS_M)
|
||||
|
||||
albedo_img = make_base_albedo_image()
|
||||
assign_fabric_material(shell, albedo_img)
|
||||
|
||||
author_logo_uv(shell, thr) # do UV2 before mask bake (mask uses UV0/active)
|
||||
bake_region_mask(shell, os.path.join(out_dir, mask_name), thr)
|
||||
save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||||
|
||||
export_reference(shell, armature, os.path.join(out_dir, glb_name))
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_dir>/average_m <out_dir> "
|
||||
"[--offset M] [--sleeve-frac F]\n"
|
||||
" or: -- <bodies_dir> <out_dir> --per-body "
|
||||
"[--bodies a,b,c] [--offset M] [--sleeve-frac F]")
|
||||
sys.exit(1)
|
||||
in_dir = argv[0]
|
||||
out_dir = argv[1]
|
||||
per_body = "--per-body" in argv
|
||||
|
||||
global SLEEVE_FRAC
|
||||
offset = None
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--sleeve-frac" in argv:
|
||||
SLEEVE_FRAC = float(argv[argv.index("--sleeve-frac") + 1])
|
||||
bodies = BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
if not per_body:
|
||||
# Single-reference mode: <in_dir> is one body's segment dir.
|
||||
offset = OFFSET_M if offset is None else offset
|
||||
body = os.path.basename(os.path.normpath(in_dir))
|
||||
author_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png", offset)
|
||||
log("DONE")
|
||||
return
|
||||
|
||||
# Per-body mode: <in_dir> is the bodies root; loop each body's own segments.
|
||||
offset = PER_BODY_OFFSET_M if offset is None else offset
|
||||
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm")
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(in_dir, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_shell(body_dir, out_dir, f"{body}.glb", f"{body}_mask.png",
|
||||
offset)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Runtime fallback + SD-fit reference compatibility: reference_mask.png
|
||||
# mirrors the reference body's mask.
|
||||
ref_mask = os.path.join(out_dir, f"{REFERENCE_BODY}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
blender_author_offset_shell_legs.py (T-1089, lower-body offset-shell garments)
|
||||
|
||||
Companion to blender_author_offset_shell.py (imported as a module — scene
|
||||
helpers, offset/solidify, albedo, rasterizer and export are reused, not
|
||||
copied). Where the base script is calibrated for TORSO garments (sleeve cut,
|
||||
collar/sleeve regions, chest logo UV), this one authors LOWER-BODY garments
|
||||
from seg_hips + leg segments with parameterized cuts/regions, so one script
|
||||
serves shorts, jeans, joggers, formal pants, swim trunks:
|
||||
|
||||
--coverage thigh|full thigh: seg_hips + seg_leg_upper_l/r (shorts)
|
||||
full: + seg_leg_lower_l/r (jeans/joggers/formal)
|
||||
--hem-frac F fraction of the hem bone's length KEPT below its
|
||||
head (bone-plane cut). Hem bone = thigh for
|
||||
--coverage thigh, calf for full. 0.78 on the thigh
|
||||
= casual shorts hem ~9 cm above the knee.
|
||||
--waistband-frac F waistband band height as a fraction of
|
||||
(waist rim z − thigh head z). The band is
|
||||
classified R in the region mask; everything else
|
||||
is G. (B/A unused — 2-region garment.)
|
||||
--fabric R,G,B flat toon-friendly base tone (luma ~0.6 keeps the
|
||||
toon_garment.gdshader luma-recolor faithful).
|
||||
--seed N albedo noise seed (deterministic output).
|
||||
|
||||
Cut/region thresholds derive PER BODY from that body's own landmarks, same
|
||||
philosophy as the base script: the hem plane comes from the thigh/calf bone
|
||||
(head→tail, identical 65-bone rig on all 11 bodies), the waist rim is the
|
||||
natural top boundary of seg_hips (its own mesh z-max), so proportions match
|
||||
across bodies. The waistline itself comes free as a segment boundary — no
|
||||
waist join geometry needed.
|
||||
|
||||
Regions (RGBA mask, toon_garment.gdshader): waistband → R (tint_0),
|
||||
body → G (tint_1). A parked logo_uv TEXCOORD_1 layer is authored for channel
|
||||
consistency with torso garments (all UVs outside [0,1] → shader draws nothing).
|
||||
|
||||
Modes mirror the base script:
|
||||
|
||||
PER-BODY (preferred for offset shells, Q-060):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
|
||||
<bodies_dir> <out_dir> --per-body \
|
||||
[--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35]
|
||||
|
||||
Writes <out_dir>/<body>.glb + <out_dir>/<body>_mask.png per body, plus
|
||||
shared base_albedo.png and reference_mask.png (= average_m's mask, runtime
|
||||
fallback for the compositor).
|
||||
|
||||
SINGLE-REFERENCE:
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
|
||||
<bodies_dir>/average_m <out_dir> [--offset 0.020] [...]
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import importlib.util
|
||||
|
||||
import bpy # noqa: F401 (Blender runtime)
|
||||
import bmesh
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base authoring module (shared helpers; main() is __main__-guarded)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
|
||||
base = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(base)
|
||||
|
||||
log = base.log
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (defaults = shorts_modern)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
COVERAGE_SEGMENTS = {
|
||||
"thigh": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"],
|
||||
"full": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r"],
|
||||
}
|
||||
HEM_BONES = {"thigh": ("thigh_l", "thigh_r"), "full": ("calf_l", "calf_r")}
|
||||
|
||||
HEM_FRAC = 0.78 # keep 78% of the thigh → hem above the knee
|
||||
WAISTBAND_FRAC = 0.35 # top 35% of waist-rim→thigh-head span = waistband (R)
|
||||
FABRIC_RGB = (0.63, 0.60, 0.53) # warm stone khaki, luma ~0.60 (toon-friendly)
|
||||
ALBEDO_SEED = 20890 # deterministic albedo noise, distinct from the tshirt
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body threshold derivation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def derive_leg_thresholds(armature, coverage, hem_frac):
|
||||
"""Hem plane from this body's own hem bone; thigh head z for the waistband
|
||||
span. All 11 bodies share the 65-bone rig, so the landmarks always exist."""
|
||||
bones = armature.data.bones
|
||||
hem_l, hem_r = HEM_BONES[coverage]
|
||||
zs = []
|
||||
thigh_heads = []
|
||||
for name in (hem_l, hem_r):
|
||||
b = bones.get(name)
|
||||
if b is None:
|
||||
raise RuntimeError(f"hem bone {name} missing on armature")
|
||||
zs.append(b.head_local.z + hem_frac * (b.tail_local.z - b.head_local.z))
|
||||
for name in ("thigh_l", "thigh_r"):
|
||||
b = bones.get(name)
|
||||
if b is None:
|
||||
raise RuntimeError(f"landmark bone {name} missing on armature")
|
||||
thigh_heads.append(b.head_local.z)
|
||||
thr = {
|
||||
"hem_z": sum(zs) / len(zs),
|
||||
"thigh_head_z": sum(thigh_heads) / len(thigh_heads),
|
||||
}
|
||||
log(f"thresholds: hem z>={thr['hem_z']:.3f} "
|
||||
f"(hem bone {hem_l}/{hem_r}, keep {hem_frac:.2f}) "
|
||||
f"thigh head z={thr['thigh_head_z']:.3f}")
|
||||
return thr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Seam weld — the body segment meshes are assembled from patches whose seam
|
||||
# vertices are coincident but DUPLICATED. Each copy's normal averages only its
|
||||
# own patch's faces, so the outward offset pulls seams apart (visible slit at
|
||||
# the front-centre of seg_hips, V-notches on the waist rim). Welding before
|
||||
# the offset merges the copies (weights/UVs are identical on coincident verts)
|
||||
# and gives one smooth normal per seam vertex.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_seams(shell, epsilon=1e-4):
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=epsilon)
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
after = len(shell.data.vertices)
|
||||
log(f"seam weld merged {before - after} duplicate verts; {after} remain")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bone-plane hem cut (z threshold — legs hang along -Z in rest pose)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def hem_cut(shell, hem_z):
|
||||
"""Delete every vert below the hem plane. A single global z threshold is
|
||||
safe: the hips segment bottoms out far above any sensible hem."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = [v for v in bm.verts if v.co.z < hem_z]
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"hem cut removed {len(to_delete)} verts below z={hem_z:.3f}; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Region mask: waistband (R) / body (G)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def bake_region_mask_legs(shell, out_path, thr, waistband_frac):
|
||||
"""Rasterize UV0 faces: waistband → R, everything else → G.
|
||||
|
||||
MUST run PRE-solidify (open boundaries still present). The waistband is
|
||||
boundary-anchored: the waist rim is the top open boundary loop of seg_hips
|
||||
(the rim DIPS ~5 cm at the navel, so a global z-max test misses the front
|
||||
row), so a face is waistband when it touches a rim-boundary vertex — that
|
||||
keys the full top face row on every tessellation — or when its centre lies
|
||||
inside the proportional band. Solidify afterwards duplicates the UV loops
|
||||
unchanged, so the baked texels serve outer shell, inner shell and rim caps
|
||||
alike."""
|
||||
W = H = base.MASK_SIZE
|
||||
buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
buf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.verts.ensure_lookup_table()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for region mask bake")
|
||||
|
||||
waist_rim_z = max(v.co.z for v in bm.verts)
|
||||
band = waistband_frac * (waist_rim_z - thr["thigh_head_z"])
|
||||
wb_z_min = waist_rim_z - band
|
||||
|
||||
# Waist-rim boundary verts: on an open boundary AND above the thigh head
|
||||
# (the only other boundaries are the leg hems, far below).
|
||||
rim_verts = set()
|
||||
for e in bm.edges:
|
||||
if e.is_boundary:
|
||||
for v in e.verts:
|
||||
if v.co.z >= thr["thigh_head_z"]:
|
||||
rim_verts.add(v.index)
|
||||
log(f"waistband: rim z={waist_rim_z:.3f} band {band*100:.1f} cm "
|
||||
f"→ R for z>={wb_z_min:.3f} or touching {len(rim_verts)} rim verts")
|
||||
|
||||
counts = {"waistband": 0, "body": 0}
|
||||
for face in bm.faces:
|
||||
in_band = face.calc_center_median().z >= wb_z_min
|
||||
touches_rim = any(v.index in rim_verts for v in face.verts)
|
||||
if in_band or touches_rim:
|
||||
color = (1.0, 0.0, 0.0, 0.0)
|
||||
counts["waistband"] += 1
|
||||
else:
|
||||
color = (0.0, 1.0, 0.0, 0.0)
|
||||
counts["body"] += 1
|
||||
for a, b, c in base._tris_from_face(face, uv_layer):
|
||||
base._raster_tri(buf, a, b, c, color, W, H)
|
||||
bm.free()
|
||||
total = max(sum(counts.values()), 1)
|
||||
log("region faces: " + " ".join(
|
||||
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
|
||||
|
||||
img = bpy.data.images.new("garment_region_mask", W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = out_path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
log(f"baked region mask -> {out_path}")
|
||||
|
||||
|
||||
def park_logo_uv(shell):
|
||||
"""Author a TEXCOORD_1 layer with every loop parked outside [0,1] — keeps
|
||||
the UV-channel layout identical to torso garments; the shader's in-box
|
||||
guard means nothing ever draws."""
|
||||
me = shell.data
|
||||
while len(me.uv_layers) > 1:
|
||||
me.uv_layers.remove(me.uv_layers[-1])
|
||||
me.uv_layers.new(name="logo_uv")
|
||||
me.uv_layers.active = me.uv_layers[0]
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
uvl = bm.loops.layers.uv.get("logo_uv")
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
loop[uvl].uv = (2.0, 2.0)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log("logo UV2 parked (no logo region on this garment)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Author one body
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_legs_shell(body_dir, out_dir, glb_name, mask_name, offset,
|
||||
coverage, hem_frac, waistband_frac, seed):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERAGE_SEGMENTS[coverage]
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
weld_seams(shell)
|
||||
thr = derive_leg_thresholds(armature, coverage, hem_frac)
|
||||
hem_cut(shell, thr["hem_z"])
|
||||
base.offset_outward(shell, offset)
|
||||
|
||||
# Region mask + parked logo UV are authored PRE-solidify: the boundary-
|
||||
# anchored waistband needs the open waist rim, and solidify duplicates all
|
||||
# UV loops unchanged so the baked texels stay valid for the final mesh.
|
||||
park_logo_uv(shell)
|
||||
bake_region_mask_legs(shell, os.path.join(out_dir, mask_name), thr,
|
||||
waistband_frac)
|
||||
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
|
||||
base.FABRIC_RGB = FABRIC_RGB
|
||||
albedo_img = base.make_base_albedo_image(seed=seed)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, glb_name))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Entry
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_dir>/average_m <out_dir> [--offset M] "
|
||||
"[--coverage thigh|full] [--hem-frac F] [--waistband-frac F] "
|
||||
"[--fabric R,G,B] [--seed N]\n"
|
||||
" or: -- <bodies_dir> <out_dir> --per-body [--bodies a,b,c] "
|
||||
"[same flags]")
|
||||
sys.exit(1)
|
||||
in_dir = argv[0]
|
||||
out_dir = argv[1]
|
||||
per_body = "--per-body" in argv
|
||||
|
||||
global FABRIC_RGB
|
||||
offset = None
|
||||
coverage = "thigh"
|
||||
hem_frac = HEM_FRAC
|
||||
waistband_frac = WAISTBAND_FRAC
|
||||
seed = ALBEDO_SEED
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--coverage" in argv:
|
||||
coverage = argv[argv.index("--coverage") + 1]
|
||||
if coverage not in COVERAGE_SEGMENTS:
|
||||
print(f"unknown --coverage {coverage}")
|
||||
sys.exit(1)
|
||||
if "--hem-frac" in argv:
|
||||
hem_frac = float(argv[argv.index("--hem-frac") + 1])
|
||||
if "--waistband-frac" in argv:
|
||||
waistband_frac = float(argv[argv.index("--waistband-frac") + 1])
|
||||
if "--fabric" in argv:
|
||||
FABRIC_RGB = tuple(
|
||||
float(c) for c in argv[argv.index("--fabric") + 1].split(","))
|
||||
if "--seed" in argv:
|
||||
seed = int(argv[argv.index("--seed") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
if not per_body:
|
||||
offset = base.OFFSET_M if offset is None else offset
|
||||
body = os.path.basename(os.path.normpath(in_dir))
|
||||
author_legs_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png",
|
||||
offset, coverage, hem_frac, waistband_frac, seed)
|
||||
log("DONE")
|
||||
return
|
||||
|
||||
offset = base.PER_BODY_OFFSET_M if offset is None else offset
|
||||
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
|
||||
f"coverage={coverage} hem_frac={hem_frac} waistband_frac={waistband_frac}")
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(in_dir, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_legs_shell(body_dir, out_dir, f"{body}.glb",
|
||||
f"{body}_mask.png", offset, coverage, hem_frac,
|
||||
waistband_frac, seed)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
blender_author_outerwear.py (T-1089, jacket_modern — open-front outerwear family)
|
||||
|
||||
Companion to blender_author_offset_shell.py: authors OUTERWEAR offset-shells
|
||||
(jacket / track-jacket / parka family) per body. It imports the base module and
|
||||
reuses its scene/join/offset/solidify/mask/logo/export machinery; everything
|
||||
garment-specific lives in the PARAMS block below, so a new outerwear piece is a
|
||||
parameter delta, not a new script.
|
||||
|
||||
What it adds over the base t-shirt shell:
|
||||
|
||||
* FULL SLEEVES — covers seg_arm_lower_l/r too; the sleeve cut moves from the
|
||||
upper arm to a WRIST cut on the lowerarm bones (same bone-plane technique).
|
||||
* 4th REGION (A = zip/trim) — a front zip placket strip running hem -> through
|
||||
the collar, plus knit cuff bands on the sleeve ends. Region layout per spec:
|
||||
collar=R, body=G, sleeves=B, zip/trim=A.
|
||||
* POSITION-PAINTED ALBEDO — instead of the base script's flat-noise albedo,
|
||||
each body gets an albedo baked in its own UV0 layout by rasterizing every
|
||||
face and painting per-texel from interpolated body-local position: zip teeth
|
||||
dashes + placket, panel seam lines (shoulder, collar, cuff, placket edges),
|
||||
hem band. The toon_garment shader recolors by LUMA, so the painted detail is
|
||||
authored as luma contrast and survives any tint choice.
|
||||
* LEFT-BREAST LOGO PATCH — the UV2 chest box shifts off-centre (the zip owns
|
||||
the centre line), scaled per body from the same bone-landmark ratios.
|
||||
|
||||
Per-body only (this family is the poster child for the Q-060 per-body route):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_outerwear.py -- \
|
||||
<bodies_root> <out_dir> \
|
||||
[--bodies a,b,c] [--offset 0.022] [--cuff-keep 0.88]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (painted albedo embedded), <body>_mask.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's painted albedo)
|
||||
<out_dir>/reference_mask.png (copy of average_m's, fallback)
|
||||
|
||||
The per-body albedo is embedded in the GLB under the image name "base_albedo"
|
||||
(tshirt convention): Godot's importer extracts it as <body>_base_albedo.png.
|
||||
average_m is deliberately authored LAST so the shared base_albedo.png sidecar
|
||||
ends up holding the reference body's pixels.
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Load the base offset-shell module (shared engine machinery).
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
|
||||
base = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(base)
|
||||
|
||||
log = base.log
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PARAMS — jacket_modern (casual zip jacket). A new outerwear garment should
|
||||
# only need to touch this block (or override via CLI where exposed).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Full-arm coverage: torso + torso_upper + both whole arms.
|
||||
SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
]
|
||||
|
||||
OFFSET_M = 0.022 # LARGEST standoff of the tops — worn over a shirt
|
||||
# (tshirt_modern outer surface ~16 mm), must neither
|
||||
# clip the body nor read skin-tight.
|
||||
CLOTH_THICKNESS_M = 0.005 # beefier cloth than the 4 mm tee
|
||||
|
||||
CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept (wrist cut — hands show)
|
||||
CUFF_BAND_FRAC = 0.22 # last fraction of the KEPT forearm = knit cuff trim (A)
|
||||
|
||||
# Region-shape ratios, anchored to the base module's average_m reference
|
||||
# landmarks (shoulder |x| 0.1919, neck len 0.0793) so they scale per body.
|
||||
ZIP_HALF_FRAC = 0.030 / 0.1919 # zip placket half-width as frac of shoulder |x|
|
||||
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below neck head
|
||||
# (t-shirt band was 0.3846 — jacket collar is taller,
|
||||
# but 0.80 caught upper-chest faces and rendered as
|
||||
# jagged shoulder "wings"; 0.55 keeps a neat band)
|
||||
COLLAR_X_MULT = 1.15 # jacket collar slightly wider than the tee band
|
||||
|
||||
# Left-breast logo patch (character-left = +X; centre line belongs to the zip).
|
||||
# Fracs of shoulder |x| / spine span, from average_m absolutes (0.035..0.115 m,
|
||||
# z 1.30..1.42 m).
|
||||
CHEST_X_LO_FRAC = 0.035 / 0.1919
|
||||
CHEST_X_HI_FRAC = 0.115 / 0.1919
|
||||
CHEST_Z_LO_FRAC = (1.30 - 1.072) / (1.5205 - 1.072)
|
||||
CHEST_Z_HI_FRAC = (1.42 - 1.072) / (1.5205 - 1.072)
|
||||
|
||||
# Painted-albedo luma palette (the shader tints by luma: tint * (luma*1.5+0.2)).
|
||||
BASE_LUMA = 0.58
|
||||
PLACKET_LUMA = 0.50 # slightly darker front placket band
|
||||
SEAM_LUMA = 0.38 # panel seam / stitch lines
|
||||
TEETH_LUMA = 0.88 # bright zip teeth dashes
|
||||
TEETH_GAP_LUMA = 0.34 # dark tape between dashes
|
||||
CUFF_LUMA = 0.48 # knit cuff band
|
||||
HEM_LUMA = 0.50 # bottom hem band
|
||||
ALBEDO_NOISE = 0.02 # woven jitter
|
||||
SEAM_W = 0.005 # seam line half-width, metres
|
||||
TEETH_HALF_W = 0.006 # zip teeth strip half-width, metres
|
||||
TEETH_PERIOD = 0.024 # dash period along Z, metres
|
||||
TEETH_DUTY = 0.014 # bright dash length within a period, metres
|
||||
HEM_BAND_M = 0.020 # hem band height, metres
|
||||
# Slate hue applied on top of luma (only visible if the region mask is missing).
|
||||
FABRIC_HUE = np.array([0.93, 0.97, 1.06], dtype=np.float32)
|
||||
|
||||
ALBEDO_SIZE = 512
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Thresholds — base derivation + outerwear extras from the same armature
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def outerwear_thresholds(armature):
|
||||
thr = base.derive_thresholds(armature)
|
||||
bones = armature.data.bones
|
||||
neck = bones.get("neck_01")
|
||||
ua_l = bones.get("upperarm_l")
|
||||
ua_r = bones.get("upperarm_r")
|
||||
spine01 = bones.get("spine_01")
|
||||
|
||||
# Shoulder |x| (same landmark the base derivation uses).
|
||||
if ua_l and ua_r:
|
||||
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
else:
|
||||
shoulder_x = 0.1919 # average_m fallback
|
||||
|
||||
# Taller, wider standing collar.
|
||||
if neck:
|
||||
neck_len = neck.tail_local.z - neck.head_local.z
|
||||
thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
|
||||
thr["collar_x_abs"] = thr["collar_x_abs"] * COLLAR_X_MULT
|
||||
|
||||
# Front zip placket.
|
||||
thr["zip_half_x"] = shoulder_x * ZIP_HALF_FRAC
|
||||
|
||||
# Wrist cut planes + cuff trim band on the lowerarm bones.
|
||||
cut_planes = []
|
||||
cuff_abs = []
|
||||
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
|
||||
b = bones.get(bone_name)
|
||||
if b is None:
|
||||
log(f"WARNING: bone {bone_name} missing — sleeve left full-length")
|
||||
continue
|
||||
head_x = b.head_local.x
|
||||
tail_x = b.tail_local.x
|
||||
cut_x = head_x + CUFF_KEEP_FRAC * (tail_x - head_x)
|
||||
band_x = head_x + (CUFF_KEEP_FRAC - CUFF_BAND_FRAC) * (tail_x - head_x)
|
||||
cut_planes.append((sign, cut_x))
|
||||
cuff_abs.append(abs(band_x))
|
||||
log(f"wrist cut {bone_name}: keep |x| up to {cut_x:.3f} "
|
||||
f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {band_x:.3f}")
|
||||
thr["wrist_cut_planes"] = cut_planes
|
||||
thr["cuff_x_abs"] = min(cuff_abs) if cuff_abs else 1e9
|
||||
|
||||
# Left-breast logo patch (replaces the base full-chest box).
|
||||
thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC)
|
||||
if neck and spine01:
|
||||
spine_lo = spine01.head_local.z
|
||||
span = neck.head_local.z - spine_lo
|
||||
thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span,
|
||||
spine_lo + CHEST_Z_HI_FRAC * span)
|
||||
log(f"outerwear thresholds: zip half {thr['zip_half_x']:.3f} "
|
||||
f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_x_abs']:.3f} "
|
||||
f"cuff |x|>={thr['cuff_x_abs']:.3f} "
|
||||
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
|
||||
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
|
||||
return thr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Wrist cut (bone-plane, same technique as the base sleeve cut)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def wrist_cut(shell, thr):
|
||||
"""Delete forearm verts beyond the wrist plane on each side."""
|
||||
cut_planes = thr.get("wrist_cut_planes", [])
|
||||
if not cut_planes:
|
||||
log("WARNING: no wrist cut planes — sleeves stay full length")
|
||||
return
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = []
|
||||
for v in bm.verts:
|
||||
for sign, thr_x in cut_planes:
|
||||
if sign > 0 and v.co.x > thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
if sign < 0 and v.co.x < thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"wrist cut removed {len(to_delete)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4-region classifier (collar=R, body=G, sleeves=B, zip/trim=A)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def classify_region_outerwear(center, thr):
|
||||
x, y, z = center.x, center.y, center.z
|
||||
ax = abs(x)
|
||||
front = y * base.FRONT_Y_SIGN > 0.0
|
||||
# Zip placket first: runs hem -> THROUGH the collar (zip-through jacket).
|
||||
if front and ax < thr["zip_half_x"]:
|
||||
return (0.0, 0.0, 0.0, 1.0) # zip/trim -> A (tint[3])
|
||||
if ax >= thr["cuff_x_abs"]:
|
||||
return (0.0, 0.0, 0.0, 1.0) # knit cuff trim -> A (tint[3])
|
||||
if ax >= thr["sleeve_x_abs"]:
|
||||
return (0.0, 0.0, 1.0, 0.0) # sleeves -> B (tint[2])
|
||||
if z >= thr["collar_z_min"] and ax < thr["collar_x_abs"]:
|
||||
return (1.0, 0.0, 0.0, 0.0) # collar -> R (tint[0])
|
||||
return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Position-painted albedo (per-body UV0 bake)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _paint_luma(x, y, z, thr, z_min):
|
||||
"""Vectorised luma paint from body-local position (arrays in, array out)."""
|
||||
v = np.full(x.shape, BASE_LUMA, dtype=np.float32)
|
||||
ax = np.abs(x)
|
||||
front = (y * base.FRONT_Y_SIGN) > 0.0
|
||||
|
||||
# Hem band along the jacket bottom.
|
||||
v = np.where(z < z_min + HEM_BAND_M, HEM_LUMA, v)
|
||||
# Knit cuff bands.
|
||||
v = np.where(ax >= thr["cuff_x_abs"], CUFF_LUMA, v)
|
||||
# Panel seam lines: shoulder (sleeve boundary), cuff start, collar base.
|
||||
v = np.where(np.abs(ax - thr["sleeve_x_abs"]) < SEAM_W, SEAM_LUMA, v)
|
||||
v = np.where(np.abs(ax - thr["cuff_x_abs"]) < SEAM_W, SEAM_LUMA, v)
|
||||
collar_seam = (np.abs(z - thr["collar_z_min"]) < SEAM_W) \
|
||||
& (ax < thr["collar_x_abs"] * 1.2)
|
||||
v = np.where(collar_seam, SEAM_LUMA, v)
|
||||
|
||||
# Front zip placket: darker band + stitch edges + dashed teeth line.
|
||||
zh = thr["zip_half_x"]
|
||||
v = np.where(front & (ax < zh), PLACKET_LUMA, v)
|
||||
v = np.where(front & (np.abs(ax - zh) < SEAM_W * 0.6), SEAM_LUMA, v)
|
||||
teeth = front & (ax < TEETH_HALF_W)
|
||||
dash = np.mod(z, TEETH_PERIOD) < TEETH_DUTY
|
||||
v = np.where(teeth & dash, TEETH_LUMA, v)
|
||||
v = np.where(teeth & ~dash, TEETH_GAP_LUMA, v)
|
||||
return v
|
||||
|
||||
|
||||
def _raster_tri_painted(lum, uv_a, uv_b, uv_c, p_a, p_b, p_c, thr, z_min, W, H):
|
||||
"""Barycentric fill of a UV triangle, painting per-texel from the
|
||||
barycentrically interpolated body-local position."""
|
||||
ax_, ay_ = uv_a.x * (W - 1), uv_a.y * (H - 1)
|
||||
bx_, by_ = uv_b.x * (W - 1), uv_b.y * (H - 1)
|
||||
cx_, cy_ = uv_c.x * (W - 1), uv_c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax_, bx_, cx_))), 0)
|
||||
maxx = min(int(np.ceil(max(ax_, bx_, cx_))), W - 1)
|
||||
miny = max(int(np.floor(min(ay_, by_, cy_))), 0)
|
||||
maxy = min(int(np.ceil(max(ay_, by_, cy_))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by_ - cy_) * (ax_ - cx_) + (cx_ - bx_) * (ay_ - cy_)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
px = xs + 0.5
|
||||
py = ys + 0.5
|
||||
w0 = ((by_ - cy_) * (px - cx_) + (cx_ - bx_) * (py - cy_)) / denom
|
||||
w1 = ((cy_ - ay_) * (px - cx_) + (ax_ - cx_) * (py - cy_)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
pos_x = w0 * p_a.x + w1 * p_b.x + w2 * p_c.x
|
||||
pos_y = w0 * p_a.y + w1 * p_b.y + w2 * p_c.y
|
||||
pos_z = w0 * p_a.z + w1 * p_b.z + w2 * p_c.z
|
||||
vals = _paint_luma(pos_x, pos_y, pos_z, thr, z_min)
|
||||
region = lum[miny:maxy + 1, minx:maxx + 1]
|
||||
region[inside] = vals[inside]
|
||||
|
||||
|
||||
def bake_painted_albedo(shell, thr, out_path, seed=1093):
|
||||
"""Bake a per-body albedo: luma-painted detail in this body's UV0 layout."""
|
||||
W = H = ALBEDO_SIZE
|
||||
lum = np.full((H, W), BASE_LUMA, dtype=np.float32)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for albedo bake")
|
||||
|
||||
z_min = min(v.co.z for v in bm.verts)
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
pos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_painted(lum, uvs[0], uvs[i], uvs[i + 1],
|
||||
pos[0], pos[i], pos[i + 1], thr, z_min, W, H)
|
||||
bm.free()
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
lum += (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
|
||||
rgb = np.clip(lum[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0)
|
||||
rgba = np.concatenate(
|
||||
[rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2).astype(np.float32)
|
||||
|
||||
img = bpy.data.images.new("garment_painted_albedo", W, H, alpha=False)
|
||||
img.pixels.foreach_set(rgba.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = out_path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
log(f"baked painted albedo -> {out_path}")
|
||||
return img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_outerwear(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
thr = outerwear_thresholds(armature)
|
||||
wrist_cut(shell, thr)
|
||||
base.offset_outward(shell, offset)
|
||||
base.solidify(shell, CLOTH_THICKNESS_M)
|
||||
|
||||
# Shared sidecar name on purpose: the exporter derives the embedded glTF
|
||||
# image name from the filepath basename, and Godot extracts embedded
|
||||
# textures as <glb>_<image>.png — so this yields <body>_base_albedo.png
|
||||
# on import, matching the tshirt_modern convention (no doubled names).
|
||||
albedo_path = os.path.join(out_dir, "base_albedo.png")
|
||||
albedo_img = bake_painted_albedo(shell, thr, albedo_path)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.author_logo_uv(shell, thr) # UV2 before mask bake (mask uses UV0/active)
|
||||
base.bake_region_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr)
|
||||
|
||||
# Honest region tally (the base bake log can't see the A channel).
|
||||
counts = {"collar": 0, "body": 0, "sleeve": 0, "zip/trim": 0}
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
for face in bm.faces:
|
||||
c = classify_region_outerwear(face.calc_center_median(), thr)
|
||||
if c[3] > 0.5:
|
||||
counts["zip/trim"] += 1
|
||||
elif c[2] > 0.5:
|
||||
counts["sleeve"] += 1
|
||||
elif c[0] > 0.5:
|
||||
counts["collar"] += 1
|
||||
else:
|
||||
counts["body"] += 1
|
||||
bm.free()
|
||||
total = max(sum(counts.values()), 1)
|
||||
log("outerwear regions: " + " ".join(
|
||||
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> "
|
||||
"[--bodies a,b,c] [--offset M] [--cuff-keep F]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
|
||||
global CUFF_KEEP_FRAC
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--cuff-keep" in argv:
|
||||
CUFF_KEEP_FRAC = float(argv[argv.index("--cuff-keep") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
# Author the reference body LAST so the shared base_albedo.png sidecar
|
||||
# (rewritten per body before each export) ends as average_m's version.
|
||||
if base.REFERENCE_BODY in bodies:
|
||||
bodies = [b for b in bodies if b != base.REFERENCE_BODY] + [base.REFERENCE_BODY]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# Route the base mask bake through the 4-region outerwear classifier and
|
||||
# the covered-segment import through the full-arm list.
|
||||
base._classify_region = classify_region_outerwear
|
||||
base.COVERED_SEGMENTS = SEGMENTS
|
||||
|
||||
log(f"outerwear per-body mode: {len(bodies)} bodies, "
|
||||
f"offset {offset*1000:.0f} mm, cuff keep {CUFF_KEEP_FRAC:.2f}")
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_outerwear(body_dir, out_dir, body, offset)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Runtime fallback: reference_mask.png mirrors average_m's mask.
|
||||
# (base_albedo.png already holds average_m's albedo — it authored last.)
|
||||
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,726 @@
|
||||
"""
|
||||
blender_author_parka.py (T-1089 wave 2 — parka_thrds, hip-length insulated parka)
|
||||
|
||||
Cold-weather parka authored as per-body offset shells — the FIRST CANON-BRANDED
|
||||
garment (thrds, the Braemar fiber cooperative, wiki/star-systems/GJ-475).
|
||||
Companion to blender_author_offset_shell.py (imported as a library): reuses the
|
||||
base module's segment join / bone-ratio thresholds / offset / solidify /
|
||||
logo-UV2 / export machinery and layers the parka geometry + texture identity on
|
||||
top, combining the proven practices of the wave-1 family:
|
||||
|
||||
* HIP-LENGTH — covers torso + torso_upper + FULL arms + the TOP of the hips
|
||||
zone: seg_hips joins the shell, the coincident torso|hips overlap band is
|
||||
WELDED before offsetting (denim practice — un-welded rings offset along
|
||||
diverging normals and open cracks), and the hem is cut at a bone-derived
|
||||
plane between the pelvis head and spine_01 head (no skirt panel below).
|
||||
The vert-threshold hem cut leaves jagged teeth, so the open hem rim is
|
||||
FLATTENED onto its own valley plane (denim flatten practice) for a clean
|
||||
straight hem, which Solidify(use_rim) then caps.
|
||||
* HOOD-DOWN ROLL — the hoodie's rolled-collar inflation with bulkier parka
|
||||
parameters (radial bulge away from the neck axis, back-biased, rim lift).
|
||||
* CHUNKY INSULATED READ — painted, not modelled: horizontal quilt channel
|
||||
lines (constant-z on the body, constant-|x| around the sleeves) with a
|
||||
soft per-channel "puff" luminance gradient; big front patch pockets with
|
||||
flaps; a storm-flap front placket with bright zip-teeth dashes; hem
|
||||
drawcord band; sleeve cuff bands. All identity is carried as LUMINANCE so
|
||||
the luma-preserving toon_garment recolor keeps it under any tint.
|
||||
* REGIONS (spec): R = hood roll + collar, G = body, B = sleeves,
|
||||
A = pocket / quilt trim (quilt lines, pockets, storm flap, cuff bands).
|
||||
Albedo and mask come from ONE shared per-texel feature-field evaluation
|
||||
(denim practice) so they always agree. The mask ships CHANNEL_PACKED with
|
||||
an alpha floor (coverall lesson: Godot's fix_alpha_border pass + Blender's
|
||||
straight-alpha PNG save both destroy a plain A channel).
|
||||
* LOGO-CAPABLE — left-breast chest patch (the storm flap owns the centre
|
||||
line), same bone-ratio box as jacket_modern; painted as a brighter patch
|
||||
rectangle kept in the G region so the decal reads on the body tint.
|
||||
|
||||
Per-body only (Q-060: offset shells author per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_parka.py -- \
|
||||
client/assets/characters/bodies client/assets/characters/clothing/parka_thrds \
|
||||
[--bodies a,b,c] [--offset 0.026] [--hem-frac 0.15]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, painted albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, that body's UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Load the base offset-shell module (shared engine machinery).
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
|
||||
base = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(base)
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[parka] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PARAMS — parka_thrds. A parka-family variant should only touch this block.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
GARMENT_ID = "parka_thrds"
|
||||
|
||||
SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
"seg_hips",
|
||||
]
|
||||
|
||||
OFFSET_M = 0.026 # chunkiest standoff of the tops family — an insulated
|
||||
# parka worn over layers (jacket_modern is 0.022)
|
||||
THICKNESS_M = 0.008 # insulated cloth (jacket 5 mm, hoodie 6 mm)
|
||||
WELD_DIST = 5e-4 # coincident segment-seam weld tolerance (denim)
|
||||
|
||||
# Hem: fraction of the pelvis->spine_01 span ABOVE the pelvis head. 0.15 lands
|
||||
# just below the hip joint (thigh head) on every body — hip-length, no skirt.
|
||||
HEM_PELVIS_FRAC = 0.15
|
||||
HEM_ALLOW_FRAC = 0.50 # hem-ring search band above the cut plane (same span)
|
||||
|
||||
# Sleeves: wrist cut + cuff band (jacket/hoodie practice — cuff anchored to the
|
||||
# ACTUAL post-cut mesh reach, the jagged cut stops 1-3 cm short of the plane).
|
||||
CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept
|
||||
CUFF_LEN_FRAC = 0.14 # cuff band length as fraction of the lowerarm
|
||||
|
||||
# Collar / hood-down roll (fractions of neck_01 length unless noted).
|
||||
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below the neck head
|
||||
COLLAR_X_MULT = 1.20 # parka collar wider than the tee band
|
||||
ROLL_OUT_FRAC = 0.50 # radial bulge magnitude (hoodie 0.40 — bulkier)
|
||||
ROLL_LIFT_FRAC = 0.16 # upward lift at the rim
|
||||
ROLL_Z_START_FRAC = 0.50 # roll influence starts this far below collar_z_min
|
||||
ROLL_REACH_COLLAR_X = 1.80 # candidate radius around neck axis (x tee collar_x)
|
||||
ROLL_FRONT_GAIN = 0.60 # bulge scale at the front...
|
||||
ROLL_BACK_GAIN = 1.25 # ...and the back (hood mass hangs behind)
|
||||
ROLL_EXPONENT = 1.80 # falloff sharpness toward the rim
|
||||
R_Z_DROP_FRONT = 0.20 # R region drop below collar_z_min (x neck_len)
|
||||
R_Z_DROP_BACK = 0.60 # asymmetric — the roll drapes lower on the back
|
||||
|
||||
# Storm flap + painted zip (metric refs scale by shoulder |x| / 0.1919).
|
||||
FLAP_HALF_FRAC = 0.026 / 0.1919 # storm-flap half-width, frac of shoulder |x|
|
||||
TEETH_HALF_M_REF = 0.0065 # zip teeth strip half-width
|
||||
TEETH_PERIOD_M_REF = 0.026 # dash period along Z
|
||||
TEETH_DUTY_M_REF = 0.015 # bright dash length within a period
|
||||
|
||||
# Big front patch pockets (x fracs of shoulder |x|, z fracs of garment span).
|
||||
POCKET_CX_FRAC = 0.44
|
||||
POCKET_HW_FRAC = 0.25
|
||||
POCKET_Z0_FRAC = 0.06 # clear of the hem drawcord band (HEM_BAND_FRAC 0.045)
|
||||
POCKET_Z1_FRAC = 0.255
|
||||
POCKET_FLAP_FRAC = 0.30 # top fraction of the pocket = flap
|
||||
|
||||
# Quilt channels (fracs of the garment span = collar_z_min - hem).
|
||||
QUILT_PERIOD_FRAC = 0.13
|
||||
QUILT_HALFW_FRAC = 0.0070 # painted line half-width
|
||||
HEM_BAND_FRAC = 0.045 # drawcord hem band height
|
||||
PUFF_AMP = 0.045 # per-channel puff luminance amplitude
|
||||
|
||||
# Left-breast logo patch (character-left = +X; the flap owns the centre line).
|
||||
# Same average_m absolutes as jacket_modern (0.035..0.115 m, z 1.30..1.42 m).
|
||||
CHEST_X_LO_FRAC = 0.035 / 0.1919
|
||||
CHEST_X_HI_FRAC = 0.115 / 0.1919
|
||||
CHEST_Z_LO_FRAC = (1.30 - 1.072) / (1.5205 - 1.072)
|
||||
CHEST_Z_HI_FRAC = (1.42 - 1.072) / (1.5205 - 1.072)
|
||||
|
||||
# Painted luma palette (toon_garment recolors by luma: tint * (luma*1.5+0.2)).
|
||||
BASE_LUMA = 0.55
|
||||
QUILT_LUMA = 0.40 # quilt channel lines
|
||||
ROLL_LUMA = 0.62 # hood roll reads lighter/lofted
|
||||
FLAP_LUMA = 0.50 # storm flap band
|
||||
POCKET_LUMA = 0.60 # pocket patch fill
|
||||
POCKET_FLAP_LUMA = 0.47 # pocket flap
|
||||
PATCH_LUMA = 0.68 # chest logo patch backing
|
||||
CUFF_LUMA = 0.46 # sleeve cuff bands
|
||||
HEM_LUMA = 0.48 # hem drawcord band
|
||||
STITCH_LUMA = 0.33 # stitch / outline lines
|
||||
TEETH_LUMA = 0.88 # bright zip teeth dashes
|
||||
TEETH_GAP_LUMA = 0.30 # dark zip tape between dashes
|
||||
ALBEDO_NOISE = 0.02 # woven jitter
|
||||
FRONT_EPS_M = 0.004 # front-facing gate on |y| (denim practice)
|
||||
|
||||
# Deep blue-grey cold-weather hue (visible when the region mask is absent;
|
||||
# with the mask bound, identity is carried by luma alone).
|
||||
FABRIC_HUE = np.array([0.62, 0.70, 0.88], dtype=np.float32)
|
||||
|
||||
TEX_SIZE = 1024 # albedo + mask (painted quilt lines need > 512)
|
||||
NOISE_SEED = 2093
|
||||
|
||||
_REF_SHOULDER_X = 0.1919 # average_m anchor (same as base/jacket)
|
||||
|
||||
# Region labels -> mask channels (spec: roll+collar=R, body=G, sleeves=B, trim=A).
|
||||
LBL_G, LBL_R, LBL_B, LBL_A = 0, 1, 2, 3
|
||||
LABEL_NAMES = ["body", "roll", "sleeve", "trim"]
|
||||
LABEL_RGBA_ARR = np.array([
|
||||
(0.0, 1.0, 0.0, 0.0), # G body
|
||||
(1.0, 0.0, 0.0, 0.0), # R hood roll + collar
|
||||
(0.0, 0.0, 1.0, 0.0), # B sleeves
|
||||
(0.0, 0.0, 0.0, 1.0), # A pocket / quilt trim
|
||||
], dtype=np.float32)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Thresholds — base derivation + parka extras from the same armature
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def parka_thresholds(armature):
|
||||
thr = base.derive_thresholds(armature)
|
||||
bones = armature.data.bones
|
||||
|
||||
def bone(name):
|
||||
b = bones.get(name)
|
||||
if b is None:
|
||||
raise RuntimeError(f"landmark bone {name} missing")
|
||||
return b
|
||||
|
||||
ua_l = bone("upperarm_l")
|
||||
ua_r = bone("upperarm_r")
|
||||
neck = bone("neck_01")
|
||||
pelvis = bone("pelvis")
|
||||
spine01 = bone("spine_01")
|
||||
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
scale = shoulder_x / _REF_SHOULDER_X
|
||||
|
||||
# Collar band (taller + wider than the tee) and hood-roll geometry.
|
||||
neck_len = neck.tail_local.z - neck.head_local.z
|
||||
tee_collar_x = thr["collar_x_abs"] # before the parka widening
|
||||
thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
|
||||
thr["collar_x_abs"] = tee_collar_x * COLLAR_X_MULT
|
||||
thr["neck_y"] = neck.head_local.y
|
||||
thr["neck_len"] = neck_len
|
||||
thr["roll_out"] = ROLL_OUT_FRAC * neck_len
|
||||
thr["roll_lift"] = ROLL_LIFT_FRAC * neck_len
|
||||
thr["roll_reach"] = ROLL_REACH_COLLAR_X * tee_collar_x
|
||||
|
||||
# Hip-length hem plane + ring-search allowance (bone-derived, per body).
|
||||
pz, sz = pelvis.head_local.z, spine01.head_local.z
|
||||
thr["hem_z"] = pz + HEM_PELVIS_FRAC * (sz - pz)
|
||||
thr["hem_allow"] = HEM_ALLOW_FRAC * (sz - pz)
|
||||
|
||||
# Wrist cut planes on the lowerarm bones (jacket practice).
|
||||
cut_planes = []
|
||||
lowerarm_len = 0.0
|
||||
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
|
||||
b = bone(bone_name)
|
||||
head_x, tail_x = b.head_local.x, b.tail_local.x
|
||||
cut_planes.append((sign, head_x + CUFF_KEEP_FRAC * (tail_x - head_x)))
|
||||
lowerarm_len = abs(tail_x - head_x)
|
||||
thr["wrist_cut_planes"] = cut_planes
|
||||
thr["cuff_len"] = CUFF_LEN_FRAC * lowerarm_len
|
||||
|
||||
# Storm flap + zip teeth.
|
||||
thr["flap_half"] = shoulder_x * FLAP_HALF_FRAC
|
||||
thr["teeth_half"] = TEETH_HALF_M_REF * scale
|
||||
thr["teeth_period"] = TEETH_PERIOD_M_REF * scale
|
||||
thr["teeth_duty"] = TEETH_DUTY_M_REF * scale
|
||||
|
||||
# Pockets (x now; z after the span is known in finalize_thresholds).
|
||||
thr["pocket_cx"] = POCKET_CX_FRAC * shoulder_x
|
||||
thr["pocket_hw"] = POCKET_HW_FRAC * shoulder_x
|
||||
|
||||
# Left-breast logo patch (replaces the base full-chest box).
|
||||
thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC)
|
||||
spine_lo = spine01.head_local.z
|
||||
span = neck.head_local.z - spine_lo
|
||||
thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span,
|
||||
spine_lo + CHEST_Z_HI_FRAC * span)
|
||||
|
||||
log(f"thresholds: hem z={thr['hem_z']:.3f} collar z>={thr['collar_z_min']:.3f} "
|
||||
f"|x|<{thr['collar_x_abs']:.3f} roll out={thr['roll_out']*1000:.0f}mm "
|
||||
f"reach={thr['roll_reach']:.3f} flap half={thr['flap_half']:.3f} "
|
||||
f"pocket cx={thr['pocket_cx']:.3f} hw={thr['pocket_hw']:.3f} "
|
||||
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
|
||||
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
|
||||
return thr
|
||||
|
||||
|
||||
def finalize_thresholds(shell, thr):
|
||||
"""Mesh-measured extras after cuts/flatten/offset/roll: the garment span
|
||||
(drives quilt/pocket/hem-band proportions) and the cuff anchor from the
|
||||
sleeves' ACTUAL post-cut reach (hoodie practice)."""
|
||||
span = thr["collar_z_min"] - thr["hem_z"]
|
||||
thr["span"] = span
|
||||
thr["quilt_period"] = QUILT_PERIOD_FRAC * span
|
||||
thr["quilt_halfw"] = QUILT_HALFW_FRAC * span
|
||||
thr["hem_band_h"] = HEM_BAND_FRAC * span
|
||||
thr["pocket_z0"] = thr["hem_z"] + POCKET_Z0_FRAC * span
|
||||
thr["pocket_z1"] = thr["hem_z"] + POCKET_Z1_FRAC * span
|
||||
thr["pocket_flap_h"] = POCKET_FLAP_FRAC * (thr["pocket_z1"] - thr["pocket_z0"])
|
||||
wrist_x = max((abs(v.co.x) for v in shell.data.vertices), default=0.0)
|
||||
thr["cuff_x0"] = wrist_x - thr["cuff_len"]
|
||||
log(f"finalized: span={span:.3f} quilt period={thr['quilt_period']*1000:.0f}mm "
|
||||
f"pocket z=({thr['pocket_z0']:.3f},{thr['pocket_z1']:.3f}) "
|
||||
f"cuff |x|>={thr['cuff_x0']:.3f} (reach {wrist_x:.3f})")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: weld, cuts, hem flatten, hood roll
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_boundaries(shell):
|
||||
"""Merge coincident segment-boundary verts (denim practice) so the offset
|
||||
can't open cracks at the torso|hips / torso|arm seams. Weights are
|
||||
identical by origin, so skinning is unaffected."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_DIST)
|
||||
merged = before - len(bm.verts)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"welded segment boundaries: {merged} verts merged "
|
||||
f"({before} -> {len(me.vertices)})")
|
||||
|
||||
|
||||
def wrist_cut(shell, thr):
|
||||
"""Delete forearm verts beyond the wrist plane on each side (jacket)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = []
|
||||
for v in bm.verts:
|
||||
for sign, thr_x in thr["wrist_cut_planes"]:
|
||||
if sign > 0 and v.co.x > thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
if sign < 0 and v.co.x < thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"wrist cut removed {len(to_delete)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def hem_cut(shell, thr):
|
||||
"""Trim everything below the hip-length hem plane (removes the seg_hips
|
||||
lower boundary, crotch and leg openings entirely)."""
|
||||
hem_z = thr["hem_z"]
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts if v.co.z < hem_z]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"hem cut at z={hem_z:.3f}: removed {len(doomed)} verts")
|
||||
|
||||
|
||||
def flatten_hem_rim(shell, thr):
|
||||
"""Pull the open hem-ring verts onto the ring's own valley plane (denim
|
||||
flatten practice) — the vert-threshold cut leaves jagged teeth; a clean
|
||||
straight hem is what Solidify(use_rim) then caps. Only boundary verts in
|
||||
the hem band move; the neck and wrist rims sit far above `hem_allow`."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
limit = thr["hem_z"] + thr["hem_allow"]
|
||||
ring = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1: # open boundary
|
||||
for v in e.verts:
|
||||
if v.co.z < limit:
|
||||
ring.add(v.index)
|
||||
if not ring:
|
||||
log("WARNING: no hem-ring verts found — hem left jagged")
|
||||
bm.free()
|
||||
return
|
||||
zs = [bm.verts[i].co.z for i in ring]
|
||||
valley = min(zs)
|
||||
teeth = max(zs) - valley
|
||||
for i in ring:
|
||||
bm.verts[i].co.z = valley
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
thr["hem_z"] = valley # paint anchors reference the clean rim
|
||||
log(f"flattened hem rim: {len(ring)} verts, teeth {teeth:.3f} m "
|
||||
f"-> plane z={valley:.3f}")
|
||||
|
||||
|
||||
def hood_roll(shell, thr):
|
||||
"""Inflate collar-band verts radially away from the neck axis to read as a
|
||||
rolled-down hood (hoodie practice, parka-bulk parameters). Runs after the
|
||||
outward offset and before solidify so the roll gets cloth thickness."""
|
||||
import math
|
||||
z_start = thr["collar_z_min"] - ROLL_Z_START_FRAC * thr["neck_len"]
|
||||
reach = thr["roll_reach"]
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
|
||||
candidates = []
|
||||
z_rim = z_start
|
||||
for v in bm.verts:
|
||||
if v.co.z < z_start:
|
||||
continue
|
||||
hd = math.hypot(v.co.x, v.co.y - thr["neck_y"])
|
||||
if hd > reach or hd < 1e-6:
|
||||
continue
|
||||
candidates.append((v, hd))
|
||||
z_rim = max(z_rim, v.co.z)
|
||||
if z_rim <= z_start or not candidates:
|
||||
log("WARNING: no hood-roll candidates found — roll skipped")
|
||||
bm.free()
|
||||
return
|
||||
|
||||
moved = 0
|
||||
for v, hd in candidates:
|
||||
t = (v.co.z - z_start) / (z_rim - z_start)
|
||||
w = max(0.0, min(1.0, t)) ** ROLL_EXPONENT
|
||||
if w <= 0.0:
|
||||
continue
|
||||
dir_h = Vector((v.co.x, v.co.y - thr["neck_y"], 0.0)) / hd
|
||||
backness = 0.5 * (1.0 + dir_h.y * -base.FRONT_Y_SIGN) # +Y = back
|
||||
gain = ROLL_FRONT_GAIN + (ROLL_BACK_GAIN - ROLL_FRONT_GAIN) * backness
|
||||
v.co += dir_h * (thr["roll_out"] * w * gain)
|
||||
v.co.z += thr["roll_lift"] * w
|
||||
moved += 1
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"hood roll: {moved} verts inflated (rim z {z_rim:.3f}, "
|
||||
f"start z {z_start:.3f})")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Feature field — ONE evaluation drives albedo luma AND region label together
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def parka_field(px, py, pz, thr):
|
||||
"""Evaluate the parka feature field at texel 3D positions (numpy arrays).
|
||||
|
||||
Returns (label uint8 array indexing LABEL_RGBA_ARR, luma float32 array).
|
||||
"""
|
||||
n = px.shape[0]
|
||||
label = np.full(n, LBL_G, dtype=np.uint8)
|
||||
luma = np.full(n, BASE_LUMA, dtype=np.float32)
|
||||
ax = np.abs(px)
|
||||
front = py * base.FRONT_Y_SIGN > FRONT_EPS_M
|
||||
ew = thr["quilt_halfw"] # shared edge/stitch line half-width
|
||||
|
||||
on_sleeve = ax >= thr["sleeve_x_abs"]
|
||||
label[on_sleeve] = LBL_B
|
||||
|
||||
# --- zones -------------------------------------------------------------
|
||||
hem = thr["hem_z"]
|
||||
hem_top = hem + thr["hem_band_h"]
|
||||
hem_band = pz <= hem_top
|
||||
cuff = ax >= thr["cuff_x0"]
|
||||
flap = front & (ax <= thr["flap_half"])
|
||||
pocket_dx = np.abs(ax - thr["pocket_cx"])
|
||||
in_pocket_z = (pz >= thr["pocket_z0"]) & (pz <= thr["pocket_z1"])
|
||||
pocket = front & in_pocket_z & (pocket_dx <= thr["pocket_hw"])
|
||||
m = ew * 3.0 # quilt keep-out margin around painted features
|
||||
pocket_pad = front & (pz >= thr["pocket_z0"] - m) & (pz <= thr["pocket_z1"] + m) \
|
||||
& (pocket_dx <= thr["pocket_hw"] + m)
|
||||
x0, x1 = thr["chest_x"]
|
||||
z0, z1 = thr["chest_z"]
|
||||
patch = front & (px >= x0) & (px <= x1) & (pz >= z0) & (pz <= z1)
|
||||
patch_pad = front & (px >= x0 - m) & (px <= x1 + m) \
|
||||
& (pz >= z0 - m) & (pz <= z1 + m)
|
||||
hd = np.hypot(px, py - thr["neck_y"])
|
||||
roll_zmin = thr["collar_z_min"] \
|
||||
- np.where(front, R_Z_DROP_FRONT, R_Z_DROP_BACK) * thr["neck_len"]
|
||||
# Sleeve texels never join the roll (hoodie precedence: sleeve check first)
|
||||
# — without the gate the deltoid caps classify as R on every body.
|
||||
roll = (pz >= roll_zmin) & ~on_sleeve \
|
||||
& (hd <= thr["roll_reach"] + thr["roll_out"] + 0.01)
|
||||
|
||||
# --- quilt channels (constant-z on the body, constant-|x| on sleeves) ---
|
||||
coord = np.where(on_sleeve, ax - thr["sleeve_x_abs"], pz - hem)
|
||||
period = thr["quilt_period"]
|
||||
tph = np.mod(coord, period) / period
|
||||
edge_d = np.minimum(tph, 1.0 - tph) * period
|
||||
quilt_ok = ~(hem_band | cuff | flap | pocket_pad | patch_pad | roll)
|
||||
qline = quilt_ok & (edge_d <= ew)
|
||||
puff = 4.0 * tph * (1.0 - tph)
|
||||
|
||||
# --- luma (paint order = precedence, later writes win) ------------------
|
||||
luma += np.where(quilt_ok, PUFF_AMP * (puff - 0.5) * 2.0, 0.0)
|
||||
luma[qline] = QUILT_LUMA
|
||||
luma[hem_band] = HEM_LUMA
|
||||
hemline = (np.abs(pz - hem_top) <= ew) & ~on_sleeve
|
||||
luma[hemline] = STITCH_LUMA
|
||||
luma[cuff] = CUFF_LUMA
|
||||
cuffline = np.abs(ax - thr["cuff_x0"]) <= ew
|
||||
luma[cuffline] = STITCH_LUMA
|
||||
|
||||
# Big patch pockets: fill, flap, stitch outline.
|
||||
luma[pocket] = POCKET_LUMA
|
||||
flap_z = thr["pocket_z1"] - thr["pocket_flap_h"]
|
||||
luma[pocket & (pz >= flap_z)] = POCKET_FLAP_LUMA
|
||||
p_outline = pocket & (
|
||||
(pocket_dx >= thr["pocket_hw"] - ew)
|
||||
| (pz <= thr["pocket_z0"] + ew)
|
||||
| (pz >= thr["pocket_z1"] - ew)
|
||||
| (np.abs(pz - flap_z) <= ew)
|
||||
)
|
||||
luma[p_outline] = STITCH_LUMA
|
||||
|
||||
# Chest logo patch backing (stays in G so the decal reads on body tint).
|
||||
luma[patch] = PATCH_LUMA
|
||||
patch_edge = patch & (
|
||||
(px <= x0 + ew) | (px >= x1 - ew) | (pz <= z0 + ew) | (pz >= z1 - ew)
|
||||
)
|
||||
luma[patch_edge] = STITCH_LUMA
|
||||
|
||||
# Storm flap + zip teeth (teeth stop under the hood roll).
|
||||
luma[flap] = FLAP_LUMA
|
||||
flap_edge = front & (np.abs(ax - thr["flap_half"]) <= ew * 0.8)
|
||||
luma[flap_edge] = STITCH_LUMA
|
||||
teeth = front & (ax <= thr["teeth_half"]) & ~roll
|
||||
dash = np.mod(pz, thr["teeth_period"]) < thr["teeth_duty"]
|
||||
luma[teeth & dash] = TEETH_LUMA
|
||||
luma[teeth & ~dash] = TEETH_GAP_LUMA
|
||||
|
||||
# Hood roll last: lofted read + a collar seam line right under it.
|
||||
rollseam = (np.abs(pz - roll_zmin) <= ew) & ~on_sleeve \
|
||||
& (hd <= thr["roll_reach"] + thr["roll_out"] + 0.02)
|
||||
luma[rollseam & ~roll] = STITCH_LUMA
|
||||
luma[roll] = ROLL_LUMA
|
||||
|
||||
# --- region labels (same masks; precedence: trim, then roll wins) -------
|
||||
label[qline] = LBL_A
|
||||
label[cuff] = LBL_A
|
||||
label[pocket] = LBL_A
|
||||
label[flap] = LBL_A
|
||||
label[roll] = LBL_R
|
||||
return label, luma
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Combined albedo + mask bake (per-texel, pre-solidify — coverall practice)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _tri_texels(a, b, c, W, H):
|
||||
"""Texels covered by UV triangle (a,b,c) with barycentric weights
|
||||
(coverall rasterizer: returns ys, xs, w0, w1, w2)."""
|
||||
empty = (np.empty(0, int),) * 2 + (np.empty(0, np.float32),) * 3
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return empty
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return empty
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return empty
|
||||
return (
|
||||
ys[inside], xs[inside],
|
||||
w0[inside].astype(np.float32),
|
||||
w1[inside].astype(np.float32),
|
||||
w2[inside].astype(np.float32),
|
||||
)
|
||||
|
||||
|
||||
def bake_albedo_and_mask(shell, thr, out_dir, body, seed):
|
||||
"""One pass over the UV0 faces: albedo and mask from a single shared
|
||||
feature-field evaluation per texel (denim practice). Baked PRE-solidify so
|
||||
there is exactly one face per texel (coverall practice)."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(seed)
|
||||
noise_buf = (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
|
||||
|
||||
alb = np.empty((H, W, 4), dtype=np.float32)
|
||||
alb[:, :, 0:3] = np.clip(
|
||||
(BASE_LUMA + noise_buf)[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0)
|
||||
alb[:, :, 3] = 1.0
|
||||
mask = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask[:, :, 1] = 1.0 # body-green background (bilinear-bleed safe)
|
||||
label_map = np.full((H, W), LBL_G, dtype=np.uint8)
|
||||
covered = np.zeros((H, W), dtype=bool)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask bake")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv for loop in loops]
|
||||
pos = [loop.vert.co for loop in loops]
|
||||
for i in range(1, len(loops) - 1):
|
||||
tri = (0, i, i + 1)
|
||||
ys, xs, w0, w1, w2 = _tri_texels(
|
||||
uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H)
|
||||
if ys.size == 0:
|
||||
continue
|
||||
px3 = w0 * pos[tri[0]].x + w1 * pos[tri[1]].x + w2 * pos[tri[2]].x
|
||||
py3 = w0 * pos[tri[0]].y + w1 * pos[tri[1]].y + w2 * pos[tri[2]].y
|
||||
pz3 = w0 * pos[tri[0]].z + w1 * pos[tri[1]].z + w2 * pos[tri[2]].z
|
||||
lab, lum = parka_field(px3, py3, pz3, thr)
|
||||
lum = np.clip(lum + noise_buf[ys, xs], 0.0, 1.0)
|
||||
alb[ys, xs, 0:3] = np.clip(
|
||||
lum[:, None] * FABRIC_HUE[None, :], 0.0, 1.0)
|
||||
mask[ys, xs] = LABEL_RGBA_ARR[lab]
|
||||
label_map[ys, xs] = lab
|
||||
covered[ys, xs] = True
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
|
||||
total = max(int(covered.sum()), 1)
|
||||
counts = np.bincount(label_map[covered], minlength=len(LABEL_NAMES))
|
||||
log(f"painted {tri_count} UV triangles; region texels: " + " ".join(
|
||||
f"{LABEL_NAMES[i]}={int(c)} ({100.0 * c / total:.1f}%)"
|
||||
for i, c in enumerate(counts)))
|
||||
|
||||
# Alpha floor (coverall lesson): Godot's fix_alpha_border import pass
|
||||
# rewrites RGB of fully-transparent texels — floor A at 2/255 everywhere.
|
||||
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
|
||||
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
img_mask = bpy.data.images.new(f"{GARMENT_ID}_mask_{body}", W, H, alpha=True)
|
||||
# Channel-packed DATA, not imagery: a straight-alpha PNG save would zero
|
||||
# the A (trim) channel (coverall lesson, verified there).
|
||||
img_mask.alpha_mode = 'CHANNEL_PACKED'
|
||||
img_mask.pixels.foreach_set(mask.reshape(-1))
|
||||
img_mask.update()
|
||||
img_mask.filepath_raw = mask_path
|
||||
img_mask.file_format = 'PNG'
|
||||
img_mask.save()
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
|
||||
img_alb = bpy.data.images.new(f"{GARMENT_ID}_albedo_{body}", W, H, alpha=False)
|
||||
img_alb.pixels.foreach_set(alb.reshape(-1))
|
||||
img_alb.update()
|
||||
return img_alb
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_parka(body_dir, out_dir, body, offset, seed):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = SEGMENTS # build_covered_mesh reads this
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
weld_boundaries(shell)
|
||||
thr = parka_thresholds(armature)
|
||||
|
||||
wrist_cut(shell, thr)
|
||||
hem_cut(shell, thr)
|
||||
flatten_hem_rim(shell, thr)
|
||||
base.offset_outward(shell, offset)
|
||||
hood_roll(shell, thr)
|
||||
finalize_thresholds(shell, thr)
|
||||
|
||||
# UV2 + bake BEFORE solidify (coverall practice: one face per texel).
|
||||
base.author_logo_uv(shell, thr)
|
||||
albedo_img = bake_albedo_and_mask(shell, thr, out_dir, body, seed)
|
||||
|
||||
base.solidify(shell, THICKNESS_M)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
# Shared sidecar name before export: the glTF exporter names the embedded
|
||||
# image after the filepath basename and Godot extracts it as
|
||||
# <glb>_<image>.png — this lands on the tshirt-convention
|
||||
# <body>_base_albedo.png (coverall/jacket practice).
|
||||
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
||||
os.path.join(out_dir, f"{body}_base_albedo.png"))
|
||||
|
||||
|
||||
def main():
|
||||
global HEM_PELVIS_FRAC
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--hem-frac F]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--hem-frac" in argv:
|
||||
HEM_PELVIS_FRAC = float(argv[argv.index("--hem-frac") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"parka per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
|
||||
f"hem-frac {HEM_PELVIS_FRAC}")
|
||||
|
||||
results = []
|
||||
for i, body in enumerate(bodies):
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_parka(body_dir, out_dir, body, offset, seed=NOISE_SEED + i)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Deterministic shared sidecars mirror the reference body (average_m).
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,718 @@
|
||||
"""
|
||||
blender_author_shoes_formal.py (T-1089 wave 2, footwear: shoes_formal)
|
||||
|
||||
Authors FORMAL SHOES (both feet, one garment — the peasant_shoes convention)
|
||||
as per-body offset shells, reusing blender_author_offset_shell.py as a library
|
||||
(scene build, join, offset, solidify, GLB export) and the denim companion's
|
||||
required bottoms/footwear practices (boundary WELD, parked logo UV2).
|
||||
|
||||
What footwear adds, as reusable parameters (sneakers/boots can re-drive this):
|
||||
|
||||
* both-feet build: seg_foot_l + seg_foot_r joined into ONE skinned mesh;
|
||||
the two shells stay disjoint (weights by construction, one draw call).
|
||||
* boundary WELD is load-bearing here, not just hygienic: a raw seg_foot is
|
||||
3-8 mesh islands (upper + a separate flat SOLE PLATE sheet + ankle-band
|
||||
slivers). remove_doubles(0.5 mm) zips them into one shell per foot whose
|
||||
only open boundary is the ankle ring (verified on average_m + child).
|
||||
* ankle TOPLINE cut: the welded ankle ring is jagged weight-threshold teeth
|
||||
(~5 cm on average_m). Verts above the ring's own valley (optionally
|
||||
+--topline-lift) are deleted and the fresh boundary is flattened UP onto
|
||||
the topline plane — a clean horizontal low-profile shoe opening. Guard
|
||||
rails clamp the topline to [0.70, 0.95] x the foot-bone head height.
|
||||
The rim ring is then RELAXED in XY (neighbour averaging, z pinned) so the
|
||||
throat reads as a smooth loafer opening instead of a jagged U.
|
||||
* TOE-BOX smoothing: the skin mesh has individual toe bumps; offsetting
|
||||
them verbatim yields a five-finger foot-glove. Forefoot verts are
|
||||
Laplacian-smoothed in feathered bands (mild at the metatarsal, strong at
|
||||
the toes) into one smooth formal toe box, then given a small extra
|
||||
standoff (--toe-extra) to buy back the clearance smoothing costs over
|
||||
the toe bumps. Band reps are deliberately MODERATE: smoothing migrates
|
||||
shell verts away from the skin they are weighted like, and under toe
|
||||
flex (Walk heel-strike / Sprint push-off) the migration error scales
|
||||
with sin(flex angle) — first authoring pass used (2,4,8) reps and the
|
||||
animated toes visibly overtook the toe box.
|
||||
* CLEARANCE ENFORCEMENT (the fix that guards all of the above): a BVH
|
||||
snapshot of the post-cut, PRE-smoothing skin surface; after offset +
|
||||
toe-extra, every shell vert closer than the shell offset to the skin
|
||||
(signed, along the skin normal) is pushed out to exactly that standoff.
|
||||
Guaranteed rest-pose clearance by construction, no matter how far the
|
||||
toe smoothing migrated verts.
|
||||
* SOLE as an offset-shell param extension: pre-offset, downward-facing
|
||||
verts (normal.z < -0.5) are recorded; post-offset they are flattened onto
|
||||
a plane so the finished exterior bottom sits --sole-mm (default 8 mm)
|
||||
below the skin's lowest point once Solidify adds cloth thickness. A final
|
||||
clamp keeps every vert on/above the plane — thin flat sole.
|
||||
* UV0 NORMALIZATION: feet occupy a tiny corner of the body texture atlas;
|
||||
since BOTH the painted albedo and the region mask are authored here on
|
||||
UV0 (skin textures are dropped), the used UV bbox is rescaled to fill
|
||||
[0,1] — ~10x texel density for the painted seams at no cost. Mirrored
|
||||
L/R UV islands may overlap; all painted features are x-symmetric, so
|
||||
overlap is harmless by construction.
|
||||
* texel-level feature painting (denim's albedo+mask-from-one-field idea):
|
||||
- albedo: painted TOE CAP LINE across the vamp, sole-edge welt stitch,
|
||||
topline edge stitch, heel counter seam — flat toon-friendly,
|
||||
identity carried by luminance (the toon_garment shader tints
|
||||
by luma, so lines survive a black default tint).
|
||||
- mask: sole -> R, upper -> G, toe cap -> B (spec regions).
|
||||
* parked logo UV2 (shoes are not logo-capable; shader samples UV2 anyway).
|
||||
|
||||
All parameters derive PER BODY from that body's own bone landmarks (foot_l /
|
||||
ball_l) and measured mesh extents (toe/heel y, skin bottom z, per-foot ankle
|
||||
ring valley), scaled by foot length against the average_m reference — the
|
||||
same proportional-ratio philosophy as base.derive_thresholds. Per-body mode
|
||||
only (offset shells author per body, Q-060).
|
||||
|
||||
Usage (shoes_formal reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_shoes_formal.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/shoes_formal \
|
||||
[--bodies average_m,child,...] [--offset 0.006] [--sole-mm 0.008] \
|
||||
[--topline-lift 0.0] [--cap-frac 0.35] [--base-rgb r,g,b] \
|
||||
[--sole-rgb r,g,b] [--plain] [--no-uv-normalize]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Coverage note (the peasant_shoes convention): footwear ships hides: [] — the
|
||||
foot skin stays VISIBLE at runtime because the shoe throat legitimately shows
|
||||
the instep (hiding the feet would open a see-through hole there). The
|
||||
chromakey QA still gates: its config passes an explicit "covers":
|
||||
["foot_l","foot_r"] so the feet are keyed even though coverage.json hides
|
||||
nothing.
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module + the denim companion (shared utilities)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(mod_name, fname):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
mod_name, os.path.join(_HERE, fname))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load("offset_shell_base", "blender_author_offset_shell.py")
|
||||
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
|
||||
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y; toes point -Y
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (all reusable across the footwear family)
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = ["seg_foot_l", "seg_foot_r"]
|
||||
|
||||
SHELL_OFFSET_M = 0.006 # slim low-profile standoff (weights exact per body)
|
||||
SOLE_TOTAL_M = 0.008 # finished exterior sole drop below the skin bottom
|
||||
TOPLINE_LIFT_M = 0.0 # extra height above the ankle-ring valley
|
||||
TOPLINE_FLOOR_FRAC = 0.70 # topline >= this frac of foot-bone head z
|
||||
TOPLINE_CEIL_FRAC = 0.95 # topline <= this frac of foot-bone head z
|
||||
CAP_BALL_FRAC = 0.35 # toe-cap line along the ball bone (head -> tail)
|
||||
SOLE_BAND_M = 0.010 # sole side band height on average_m (R region)
|
||||
SEAM_W_M = 0.0030 # painted seam width on average_m
|
||||
SEAM_W_MIN_M = 0.0016 # floor so child seams don't alias away
|
||||
HEEL_SEAM_FRAC = 0.16 # heel counter seam, fraction of foot len from heel
|
||||
MIN_ISLAND_VERTS = 10 # post-cut sliver cleanup threshold
|
||||
RIM_RELAX_PASSES = 2 # XY neighbour-average passes on the topline ring
|
||||
|
||||
# Convex toe box (shared base.convex_toe_box) — sleek, low, tapered but SMOOTH.
|
||||
TOE_EXT_M = 0.008 # nose extension past the longest toe (m)
|
||||
TOE_WMARGIN_M = 0.0025 # half-width padding (snug formal last)
|
||||
TOE_HCLEAR_M = 0.004 # vertical headroom above the toes (low profile)
|
||||
TOE_FEATHER_M = 0.018 # blend band behind the ball
|
||||
TEX_SIZE = 1024
|
||||
UV_NORMALIZE = True # rescale used UV bbox to fill [0,1]
|
||||
PLAIN = False # --plain: skip painted stitch lines
|
||||
|
||||
# Formal leather style (luma carries the detail; runtime tints recolor).
|
||||
LEATHER_RGB = (0.320, 0.315, 0.330) # upper mid-grey leather
|
||||
SOLE_RGB = (0.235, 0.230, 0.240) # sole band slightly darker
|
||||
STITCH_RGB = (0.560, 0.550, 0.570) # painted seam thread (lighter luma)
|
||||
ALBEDO_NOISE = 0.015
|
||||
NOISE_SEED = 3089
|
||||
|
||||
# Reference proportions (average_m) the fractions were calibrated against.
|
||||
_REF_FOOT_LEN = 0.2704 # heel y (+0.1374) - toe y (-0.1330)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FootLandmarks:
|
||||
"""Cut/mask/paint parameters derived from one body's bones + mesh."""
|
||||
|
||||
def __init__(self, armature, mesh):
|
||||
bones = armature.data.bones
|
||||
foot = bones.get("foot_l")
|
||||
ball = bones.get("ball_l")
|
||||
if foot is None or ball is None:
|
||||
raise RuntimeError("foot_l/ball_l missing — not the 65-bone rig?")
|
||||
self.ankle_z = foot.head_local.z
|
||||
ys = [v.co.y for v in mesh.vertices]
|
||||
zs = [v.co.z for v in mesh.vertices]
|
||||
self.toe_y = min(ys) # toes point -Y (FRONT_Y_SIGN)
|
||||
self.heel_y = max(ys)
|
||||
self.skin_min_z = min(zs)
|
||||
self.foot_len = self.heel_y - self.toe_y
|
||||
self.s = self.foot_len / _REF_FOOT_LEN
|
||||
# Toe-cap line sits along the ball bone (metatarsal -> toes).
|
||||
self.ball_head_y = ball.head_local.y
|
||||
self.ball_tail_y = ball.tail_local.y
|
||||
self.cap_y = self.ball_head_y + CAP_BALL_FRAC * (
|
||||
self.ball_tail_y - self.ball_head_y)
|
||||
self.heel_seam_y = self.heel_y - HEEL_SEAM_FRAC * self.foot_len
|
||||
# Geometry planes: Solidify adds CLOTH_THICKNESS_M outward (down at
|
||||
# the sole), so the pre-solidify flatten plane sits thickness higher.
|
||||
self.sole_plane = (self.skin_min_z - SOLE_TOTAL_M
|
||||
+ base.CLOTH_THICKNESS_M)
|
||||
self.sole_top = self.sole_plane + SOLE_BAND_M * self.s
|
||||
self.seam_w = max(SEAM_W_M * self.s, SEAM_W_MIN_M)
|
||||
self.topline = {} # per foot side ('L'/'R'), set by ankle cut
|
||||
log(f"landmarks: ankle_z={self.ankle_z:.4f} foot_len={self.foot_len:.4f} "
|
||||
f"(s={self.s:.3f}) cap_y={self.cap_y:.4f} "
|
||||
f"heel_seam_y={self.heel_seam_y:.4f} skin_min_z={self.skin_min_z:.4f} "
|
||||
f"sole_plane={self.sole_plane:.4f} sole_top={self.sole_top:.4f} "
|
||||
f"seam_w={self.seam_w * 1000:.1f}mm")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def recalc_normals(shell):
|
||||
"""Consistent outward face normals pre-offset (the raw foot carries a
|
||||
separately-authored sole plate whose orientation is not guaranteed)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log("recalculated outward face normals")
|
||||
|
||||
|
||||
def _side(x):
|
||||
return "L" if x >= 0.0 else "R"
|
||||
|
||||
|
||||
def ankle_cut_and_flatten(shell, lm, lift):
|
||||
"""Per foot: topline = clamp(ankle-ring valley + lift); delete verts above
|
||||
it; drop disconnected slivers; flatten the fresh boundary UP onto the
|
||||
plane. Lifting (not lowering) is safe for footwear: the rim sits a full
|
||||
shell-offset OUTSIDE the skin, so raising it only deepens the overlap
|
||||
with the (visible) ankle skin — cloth over skin, never a gap."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
|
||||
# 1. Per-foot ankle-ring valley (the welded shell's only open boundary).
|
||||
ring_z = {"L": [], "R": []}
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
for v in e.verts:
|
||||
ring_z[_side(v.co.x)].append(v.co.z)
|
||||
floor_z = TOPLINE_FLOOR_FRAC * lm.ankle_z
|
||||
ceil_z = TOPLINE_CEIL_FRAC * lm.ankle_z
|
||||
for side in ("L", "R"):
|
||||
if not ring_z[side]:
|
||||
raise RuntimeError(f"no ankle boundary ring on side {side}")
|
||||
valley = min(ring_z[side])
|
||||
lm.topline[side] = min(max(valley + lift, floor_z), ceil_z)
|
||||
log(f"topline {side}: valley={valley:.4f} -> {lm.topline[side]:.4f} "
|
||||
f"(guards [{floor_z:.4f}, {ceil_z:.4f}], ring teeth "
|
||||
f"{max(ring_z[side]) - valley:.4f} m)")
|
||||
|
||||
# 2. Cut above the topline.
|
||||
doomed = [v for v in bm.verts if v.co.z > lm.topline[_side(v.co.x)]]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
log(f"ankle cut removed {len(doomed)} verts")
|
||||
|
||||
# 3. Sliver cleanup: islands the cut disconnected.
|
||||
bm.verts.ensure_lookup_table()
|
||||
seen = set()
|
||||
doomed_isl = []
|
||||
for v in bm.verts:
|
||||
if v.index in seen:
|
||||
continue
|
||||
stack, isl = [v], set()
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
if cur.index in isl:
|
||||
continue
|
||||
isl.add(cur.index)
|
||||
for e in cur.link_edges:
|
||||
o = e.other_vert(cur)
|
||||
if o.index not in isl:
|
||||
stack.append(o)
|
||||
seen |= isl
|
||||
if len(isl) < MIN_ISLAND_VERTS:
|
||||
doomed_isl.extend(isl)
|
||||
if doomed_isl:
|
||||
bm.verts.ensure_lookup_table()
|
||||
bmesh.ops.delete(bm, geom=[bm.verts[i] for i in doomed_isl],
|
||||
context='VERTS')
|
||||
log(f"removed {len(doomed_isl)} sliver-island verts")
|
||||
|
||||
# 4. Flatten the fresh jagged boundary UP onto the topline plane.
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
lifted = 0
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
for v in e.verts:
|
||||
tl = lm.topline[_side(v.co.x)]
|
||||
if v.co.z != tl:
|
||||
v.co.z = tl
|
||||
lifted += 1
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened ankle rims: {lifted} boundary verts -> topline planes")
|
||||
|
||||
|
||||
def relax_rim(shell, lm, passes):
|
||||
"""XY neighbour-averaging over the topline boundary ring (z pinned to the
|
||||
topline) so the throat opening reads smooth. Mild by design: the ring
|
||||
sits a full shell-offset outside the skin, and 2 half-weight passes stay
|
||||
well inside that budget."""
|
||||
if passes <= 0:
|
||||
return
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
adj = {}
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
a, b = e.verts
|
||||
adj.setdefault(a.index, []).append(b.index)
|
||||
adj.setdefault(b.index, []).append(a.index)
|
||||
for _ in range(passes):
|
||||
new_xy = {}
|
||||
for i, nbrs in adj.items():
|
||||
if not nbrs:
|
||||
continue
|
||||
ax = sum(bm.verts[j].co.x for j in nbrs) / len(nbrs)
|
||||
ay = sum(bm.verts[j].co.y for j in nbrs) / len(nbrs)
|
||||
v = bm.verts[i]
|
||||
new_xy[i] = (0.5 * v.co.x + 0.5 * ax, 0.5 * v.co.y + 0.5 * ay)
|
||||
for i, (x, y) in new_xy.items():
|
||||
bm.verts[i].co.x = x
|
||||
bm.verts[i].co.y = y
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"relaxed topline rim: {len(adj)} verts, {passes} XY passes")
|
||||
|
||||
|
||||
def snapshot_skin_bvh(shell):
|
||||
"""BVH of the current (post-cut, pre-smoothing) skin surface — at this
|
||||
stage the shell verts still ARE the skin verts, so this is the reference
|
||||
every later deformation is measured against."""
|
||||
import mathutils.bvhtree
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bvh = mathutils.bvhtree.BVHTree.FromBMesh(bm)
|
||||
bm.free()
|
||||
log("snapshotted skin surface BVH (clearance reference)")
|
||||
return bvh
|
||||
|
||||
|
||||
def enforce_clearance(shell, skin_bvh, min_clearance, skip_forward_of_u=None):
|
||||
"""Push any shell vert closer than `min_clearance` to the skin snapshot
|
||||
out to exactly that standoff (along the skin normal). Protects the instep /
|
||||
throat, where the rim relax can migrate verts toward the skin.
|
||||
|
||||
The toe zone is EXCLUDED (skip_forward_of_u): base.convex_toe_box builds an
|
||||
analytic cap that already stands off the skin by construction; re-snapping
|
||||
it to the skin here would re-imprint the individual toes (the original
|
||||
bug). Verts with forward coord u = y*FRONT_Y_SIGN > skip_forward_of_u are
|
||||
left untouched."""
|
||||
me = shell.data
|
||||
pushed = 0
|
||||
worst = 0.0
|
||||
for v in me.vertices:
|
||||
if skip_forward_of_u is not None and \
|
||||
(v.co.y * FRONT_Y_SIGN) > skip_forward_of_u:
|
||||
continue
|
||||
hit = skin_bvh.find_nearest(v.co)
|
||||
if hit is None or hit[0] is None:
|
||||
continue
|
||||
location, normal, _idx, _dist = hit
|
||||
signed = (v.co - location).dot(normal)
|
||||
if signed < min_clearance:
|
||||
v.co = location + normal * min_clearance
|
||||
pushed += 1
|
||||
worst = max(worst, min_clearance - signed)
|
||||
me.update()
|
||||
log(f"enforced clearance {min_clearance * 1000:.1f} mm: pushed {pushed} "
|
||||
f"verts (worst deficit {worst * 1000:.1f} mm)")
|
||||
|
||||
|
||||
def classify_sole_verts(shell):
|
||||
"""Indices of downward-facing verts (the foot underside), pre-offset."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
idx = [v.index for v in bm.verts if v.normal.z < -0.5]
|
||||
bm.free()
|
||||
log(f"classified {idx and len(idx) or 0} sole (downward-normal) verts")
|
||||
return idx
|
||||
|
||||
|
||||
def flatten_sole(shell, sole_idx, sole_plane):
|
||||
"""Post-offset: pull the underside onto the flat sole plane, then clamp
|
||||
everything on/above it (toe rounding can dip below after the offset)."""
|
||||
me = shell.data
|
||||
for i in sole_idx:
|
||||
me.vertices[i].co.z = sole_plane
|
||||
clamped = 0
|
||||
for v in me.vertices:
|
||||
if v.co.z < sole_plane:
|
||||
v.co.z = sole_plane
|
||||
clamped += 1
|
||||
me.update()
|
||||
log(f"flattened sole: {len(sole_idx)} verts -> z={sole_plane:.4f} "
|
||||
f"(+{clamped} clamped)")
|
||||
|
||||
|
||||
def clamp_topline_residue(shell, lm):
|
||||
"""Post-solidify safety clamp: rim-adjacent verts the Solidify pushed
|
||||
above the topline get squashed back onto it (denim waist pattern)."""
|
||||
me = shell.data
|
||||
n = 0
|
||||
for v in me.vertices:
|
||||
tl = lm.topline[_side(v.co.x)]
|
||||
if v.co.z > tl:
|
||||
v.co.z = tl
|
||||
n += 1
|
||||
me.update()
|
||||
if n:
|
||||
log(f"clamped {n} residual topline verts")
|
||||
|
||||
|
||||
def normalize_uv0(shell):
|
||||
"""Rescale the used UV0 bbox to fill [0,1] (uniform scale, aspect kept).
|
||||
Feet use a tiny corner of the body atlas; both the albedo and the mask
|
||||
are authored here on UV0, so reclaiming the space is free texel density."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
uvl = bm.loops.layers.uv[0]
|
||||
us, vs = [], []
|
||||
for f in bm.faces:
|
||||
for lo in f.loops:
|
||||
us.append(lo[uvl].uv.x)
|
||||
vs.append(lo[uvl].uv.y)
|
||||
u0, u1, v0, v1 = min(us), max(us), min(vs), max(vs)
|
||||
span = max(u1 - u0, v1 - v0)
|
||||
if span < 1e-6:
|
||||
bm.free()
|
||||
log("WARNING: degenerate UV bbox — normalization skipped")
|
||||
return
|
||||
scale = 0.96 / span
|
||||
for f in bm.faces:
|
||||
for lo in f.loops:
|
||||
uv = lo[uvl].uv
|
||||
uv.x = 0.02 + (uv.x - u0) * scale
|
||||
uv.y = 0.02 + (uv.y - v0) * scale
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"normalized UV0: bbox ({u0:.3f},{v0:.3f})..({u1:.3f},{v1:.3f}) "
|
||||
f"-> [0.02,0.98] (x{scale:.1f} density)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions.
|
||||
|
||||
Regions (spec): sole -> R, upper -> G, toe cap -> B.
|
||||
Painted lines (albedo only): toe cap line, sole welt stitch, topline edge
|
||||
stitch, heel counter seam.
|
||||
"""
|
||||
n = px.shape[0]
|
||||
tl = np.where(px >= 0.0, lm.topline.get("L", 1.0), lm.topline.get("R", 1.0))
|
||||
w2 = lm.seam_w * 0.5
|
||||
|
||||
sole = pz < lm.sole_top
|
||||
cap = (~sole) & (py <= lm.cap_y)
|
||||
|
||||
# --- albedo -------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = LEATHER_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
for c in range(3):
|
||||
alb[sole, c] = SOLE_RGB[c] + noise[sole]
|
||||
|
||||
if not PLAIN:
|
||||
capline = (~sole) & (np.abs(py - lm.cap_y) < w2)
|
||||
welt = np.abs(pz - lm.sole_top) < w2
|
||||
topstitch = (~sole) & (np.abs(pz - (tl - 3.0 * w2)) < w2)
|
||||
heelseam = (~sole) & (py > 0.0) \
|
||||
& (np.abs(py - lm.heel_seam_y) < w2)
|
||||
thread = capline | welt | topstitch | heelseam
|
||||
for c in range(3):
|
||||
alb[thread, c] = STITCH_RGB[c]
|
||||
|
||||
# --- region mask: sole R / upper G / toe cap B ---------------------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
mask[sole, 0] = 1.0
|
||||
mask[cap, 2] = 1.0
|
||||
mask[~(sole | cap), 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the footwear field, write albedo + mask together."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = LEATHER_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = upper green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"shoes_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"shoes_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
|
||||
# Re-save the albedo under the SHARED sidecar name and leave the image
|
||||
# datablock pointing there: the glTF exporter derives the embedded image
|
||||
# name from the filepath basename, so the GLB carries "base_albedo" and
|
||||
# Godot's extract-on-import lands exactly on <body>_base_albedo.png (the
|
||||
# wave-1 convention) instead of doubling to <body>_<body>_base_albedo.png.
|
||||
# The pixels at base_albedo.png are THIS body's during its export; main()
|
||||
# restores the reference body's copy after the loop.
|
||||
shared_path = os.path.join(os.path.dirname(albedo_path), "base_albedo.png")
|
||||
albedo_img.filepath_raw = shared_path
|
||||
albedo_img.save()
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_shoes(body_dir, out_dir, body, offset, topline_lift):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell) # zips upper + sole plate + ankle slivers
|
||||
recalc_normals(shell)
|
||||
|
||||
lm = FootLandmarks(armature, shell.data)
|
||||
ankle_cut_and_flatten(shell, lm, topline_lift)
|
||||
relax_rim(shell, lm, RIM_RELAX_PASSES)
|
||||
sole_idx = classify_sole_verts(shell) # skin downward normals
|
||||
skin_bvh = snapshot_skin_bvh(shell) # instep clearance reference
|
||||
# Smooth convex toe box (replaces Laplacian smooth + skin-conforming clamp,
|
||||
# which re-imprinted the individual toes). Runs on the raw skin toe so the
|
||||
# cap encloses the real toes; offset then adds standoff.
|
||||
ball_u = lm.ball_head_y * FRONT_Y_SIGN
|
||||
base.convex_toe_box(
|
||||
shell, armature,
|
||||
extension=TOE_EXT_M * lm.s, width_margin=TOE_WMARGIN_M * lm.s,
|
||||
height_clear=TOE_HCLEAR_M * lm.s, feather_m=TOE_FEATHER_M * lm.s)
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
# Instep/throat clearance only — the toe zone is excluded so the analytic
|
||||
# cap is never re-snapped to the skin toes.
|
||||
enforce_clearance(shell, skin_bvh, offset, skip_forward_of_u=ball_u)
|
||||
flatten_sole(shell, sole_idx, lm.sole_plane)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
clamp_topline_residue(shell, lm)
|
||||
|
||||
if UV_NORMALIZE:
|
||||
normalize_uv0(shell)
|
||||
denim.author_parked_uv2(shell) # shoes are not logo-capable
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global CAP_BALL_FRAC, LEATHER_RGB, SOLE_RGB, SOLE_TOTAL_M, PLAIN
|
||||
global UV_NORMALIZE
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--sole-mm M] [--topline-lift M] [--cap-frac F] "
|
||||
"[--base-rgb r,g,b] [--sole-rgb r,g,b] [--plain] "
|
||||
"[--no-uv-normalize]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = SHELL_OFFSET_M
|
||||
topline_lift = TOPLINE_LIFT_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--sole-mm" in argv:
|
||||
SOLE_TOTAL_M = float(argv[argv.index("--sole-mm") + 1])
|
||||
if "--topline-lift" in argv:
|
||||
topline_lift = float(argv[argv.index("--topline-lift") + 1])
|
||||
if "--cap-frac" in argv:
|
||||
CAP_BALL_FRAC = float(argv[argv.index("--cap-frac") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
LEATHER_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
if "--sole-rgb" in argv:
|
||||
SOLE_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--sole-rgb") + 1].split(","))
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
if "--no-uv-normalize" in argv:
|
||||
UV_NORMALIZE = False
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"shoes per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{offset * 1000:.0f} mm, sole {SOLE_TOTAL_M * 1000:.0f} mm, "
|
||||
f"plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_shoes(body_dir, out_dir, body, offset, topline_lift)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,676 @@
|
||||
"""
|
||||
blender_author_slides.py (T-1089 wave 2, slides — open swim footwear)
|
||||
|
||||
Authors SLIDES (open sandal: flat sole slab + one broad strap band across the
|
||||
midfoot) as per-body offset shells, reusing blender_author_offset_shell.py as
|
||||
a library (scene build/join, offset, solidify, GLB export) and the denim
|
||||
companion's required bottoms/footwear practices (boundary WELD of coincident
|
||||
segment-seam verts before offsetting; the parked logo UV2 layer).
|
||||
|
||||
First offset-shell FOOTWEAR: follows the peasant_shoes both-feet convention
|
||||
(one garment covers seg_foot_l + seg_foot_r), and adds what feet need that no
|
||||
torso/leg companion provides, as reusable parameters:
|
||||
|
||||
* STRAP band cut — the foot shell is cut down to just the midfoot band
|
||||
between two Y planes derived from that body's own foot bones
|
||||
(fractions of the ball_l/r head -> foot_l/r head span, identical 65-bone
|
||||
rig on all 11 bodies). Probe evidence: the seg_foot ankle rim (the
|
||||
weight-threshold splitter's jagged boundary, 3.3-8.3 cm teeth post-weld)
|
||||
stays ABOVE y-fraction ~0.62 of that span on every body, so a band cut at
|
||||
<= 0.58 removes the entire jagged rim by construction. The band keeps the
|
||||
full cross-section ring (including under-foot skin) so the strap-to-sole
|
||||
join is gap-free by construction (the coverall waist-join pattern); the
|
||||
hidden under-foot part is swallowed by the sole slab.
|
||||
* clean strap rims by PLANE BISECT — the wave-1 delete-then-flatten rim
|
||||
practice is too crude for the foot's large instep triangles (it notches
|
||||
the strap crest); the band is instead cut with two exact bisect planes,
|
||||
which yields the same clean-plane end state the denim flatten was after,
|
||||
by construction (bmesh interpolates weights/UVs on the new edge verts).
|
||||
A rim check verifies every open-edge vert sits on a cut plane.
|
||||
* SOLE slab construction — per foot, the 2D convex hull (monotone chain) of
|
||||
that foot's full footprint, expanded radially by a margin, extruded into
|
||||
a prism from below the skin's lowest point to just above it (the foot
|
||||
visually rests IN the footbed). Sole verts receive skin weights by
|
||||
nearest-vertex transfer from the pre-cut foot snapshot, so the sole bends
|
||||
with the foot/ball bones during Walk/Sprint toe-off. A FLEX CREASE ring
|
||||
is bisected into each prism at the ball-joint line so the slab hinges
|
||||
where the foot hinges (QA evidence: without it, toe skin dips through
|
||||
the linearly-interpolated top face in deep-crouch toe-off).
|
||||
* under-sole clamp — strap ring verts that offset/solidify pushed below the
|
||||
sole interior are clamped onto a plane inside the slab (invisible), so the
|
||||
strap never pokes out of the sole bottom.
|
||||
* deterministic region UVs — the garment is fully re-UV'd (the foot's body
|
||||
atlas layout is useless for garment paint): strap faces pack into the left
|
||||
half of UV space, sole faces into the right half, each planar-projected by
|
||||
dominant normal axis into three stacked tiles. Faces of one region may
|
||||
overlap in UV (they share one flat colour), but strap and sole texels are
|
||||
DISJOINT by construction — no cross-region contamination, no operator
|
||||
(bpy.ops.uv.*) dependency in background mode.
|
||||
* albedo + region mask painted together per face: sole -> R (tint_0),
|
||||
strap -> G (tint_1); bright default albedo (spec: swim family, bright).
|
||||
Painted texels are dilated outward so bilinear/mip bleed at tile edges
|
||||
never lands on an untinted texel.
|
||||
|
||||
Usage (per-body only — offset shells author per body, Q-060):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_slides.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/slides \
|
||||
[--bodies average_m,child,...] [--offset 0.005] \
|
||||
[--strap-y0 0.10] [--strap-y1 0.58] \
|
||||
[--sole-margin 0.007] [--sole-embed 0.009] [--sole-drop 0.010] \
|
||||
[--strap-rgb 0.93,0.35,0.20] [--sole-rgb 0.82,0.83,0.85] [--seed N]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module + the denim companion (shared machinery)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(name, fname):
|
||||
spec = importlib.util.spec_from_file_location(name, os.path.join(_HERE, fname))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load("offset_shell_base", "blender_author_offset_shell.py")
|
||||
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
|
||||
|
||||
log = base.log
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = ["seg_foot_l", "seg_foot_r"]
|
||||
|
||||
STRAP_OFFSET_M = 0.005 # strap standoff from skin (slides hug the foot)
|
||||
STRAP_THICKNESS_M = 0.005 # Solidify thickness (chunky foam strap)
|
||||
|
||||
# Strap band window as fractions of the ball-head -> foot(ankle)-head Y span,
|
||||
# measured from the ball. Probe: the jagged seg_foot ankle rim starts at
|
||||
# fraction ~0.62 on every body, so y1 <= 0.58 excludes it by construction.
|
||||
STRAP_Y0_FRAC = 0.10
|
||||
STRAP_Y1_FRAC = 0.58
|
||||
|
||||
# Sole slab (metres on average_m; scaled by each body's foot-length ratio).
|
||||
SOLE_MARGIN_M = 0.007 # radial footprint expansion beyond the skin hull
|
||||
SOLE_EMBED_M = 0.009 # slab top above the foot's lowest skin point
|
||||
SOLE_DROP_M = 0.010 # slab bottom below the foot's lowest skin point
|
||||
CLAMP_INSET_M = 0.003 # strap under-foot verts clamped this far above
|
||||
# the slab bottom (kept inside the sole)
|
||||
|
||||
# Bright default (spec: swim family). Texture carries identity; the runtime
|
||||
# toon_garment.gdshader recolors per region via luma, so these tones ARE the
|
||||
# default look and default_tints should match them.
|
||||
STRAP_RGB = (0.93, 0.35, 0.20) # bright coral strap (G region, tint_1)
|
||||
SOLE_RGB = (0.82, 0.83, 0.85) # off-white foam sole (R region, tint_0)
|
||||
ALBEDO_NOISE = 0.018 # +/- jitter, subtle foam/EVA feel
|
||||
NOISE_SEED = 3089
|
||||
|
||||
TEX_SIZE = 512 # slides are small; 512 is plenty
|
||||
DILATE_PX = 6 # painted-texel dilation into the background
|
||||
|
||||
_REF_FOOT_SPAN = 0.2211 # average_m: foot_l head y (0.0875) - ball_l tail y (-0.1336)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FootLandmarks:
|
||||
"""Per-side cut/sole parameters from one body's own foot bones + mesh."""
|
||||
|
||||
def __init__(self, armature):
|
||||
bones = armature.data.bones
|
||||
self.sides = {}
|
||||
spans = []
|
||||
for side, sign in (("l", +1), ("r", -1)):
|
||||
foot = bones.get(f"foot_{side}")
|
||||
ball = bones.get(f"ball_{side}")
|
||||
if foot is None or ball is None:
|
||||
raise RuntimeError(
|
||||
f"foot_{side}/ball_{side} missing — not the 65-bone rig?")
|
||||
ankle_y = foot.head_local.y
|
||||
ball_y = ball.head_local.y
|
||||
toe_y = ball.tail_local.y
|
||||
span = ankle_y - toe_y
|
||||
spans.append(span)
|
||||
self.sides[sign] = {
|
||||
"y0": ball_y + STRAP_Y0_FRAC * (ankle_y - ball_y),
|
||||
"y1": ball_y + STRAP_Y1_FRAC * (ankle_y - ball_y),
|
||||
"ball_y": ball_y,
|
||||
}
|
||||
self.scale = (sum(spans) / len(spans)) / _REF_FOOT_SPAN
|
||||
for sign in (+1, -1):
|
||||
s = self.sides[sign]
|
||||
log(f"landmarks side {'L' if sign > 0 else 'R'}: "
|
||||
f"strap y[{s['y0']:.4f},{s['y1']:.4f}]")
|
||||
log(f"foot scale vs average_m: {self.scale:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: snapshot, strap cut, rim flatten, sole build
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def snapshot_skin(shell):
|
||||
"""Record post-weld skin verts: positions + per-vertex group weights.
|
||||
|
||||
Used later for the sole's nearest-vertex weight transfer, after the strap
|
||||
cut has thrown most of the foot away.
|
||||
"""
|
||||
me = shell.data
|
||||
pos = np.array([(v.co.x, v.co.y, v.co.z) for v in me.vertices],
|
||||
dtype=np.float64)
|
||||
weights = [
|
||||
[(g.group, g.weight) for g in v.groups if g.weight > 0.0]
|
||||
for v in me.vertices
|
||||
]
|
||||
return pos, weights
|
||||
|
||||
|
||||
def strap_cut(shell, lm):
|
||||
"""Cut the foot shell down to the strap band with two exact plane bisects.
|
||||
|
||||
The wave-1 bottoms practice (delete-then-flatten) is too crude here: the
|
||||
foot mesh's instep triangles are large relative to the 5-7 cm band, so
|
||||
vertex deletion notches the strap crest and rim-snapping folds it. Bisect
|
||||
planes give clean straight rims BY CONSTRUCTION (bmesh interpolates
|
||||
weights/UVs on the new edge verts), which is the same end state the denim
|
||||
flatten was after. The rig's feet are mirrored, so one Y window (side
|
||||
average) serves both feet.
|
||||
"""
|
||||
y0 = (lm.sides[+1]["y0"] + lm.sides[-1]["y0"]) / 2.0
|
||||
y1 = (lm.sides[+1]["y1"] + lm.sides[-1]["y1"]) / 2.0
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.bisect_plane(
|
||||
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
|
||||
plane_co=(0.0, y0, 0.0), plane_no=(0.0, 1.0, 0.0),
|
||||
clear_inner=True) # drop y < y0 (toe side)
|
||||
bmesh.ops.bisect_plane(
|
||||
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
|
||||
plane_co=(0.0, y1, 0.0), plane_no=(0.0, 1.0, 0.0),
|
||||
clear_outer=True) # drop y > y1 (ankle side, incl. the jagged rim)
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"strap band bisect y[{y0:.4f},{y1:.4f}]: {before} -> "
|
||||
f"{len(shell.data.vertices)} verts")
|
||||
if len(shell.data.vertices) == 0:
|
||||
raise RuntimeError("strap cut removed everything — window wrong?")
|
||||
|
||||
|
||||
def check_strap_rims(shell, lm):
|
||||
"""Verify the band's open edges sit ON the two cut planes (bisect gives
|
||||
this by construction; residue means ankle-rim leakage into the window)."""
|
||||
y0 = (lm.sides[+1]["y0"] + lm.sides[-1]["y0"]) / 2.0
|
||||
y1 = (lm.sides[+1]["y1"] + lm.sides[-1]["y1"]) / 2.0
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
off = [i for i in boundary
|
||||
if min(abs(bm.verts[i].co.y - y0), abs(bm.verts[i].co.y - y1)) > 1e-4]
|
||||
bm.free()
|
||||
log(f"strap rims: {len(boundary)} boundary verts, {len(off)} off-plane "
|
||||
f"(expected 0)")
|
||||
if off:
|
||||
log("WARNING: off-plane rim verts — ankle-rim residue inside the "
|
||||
"band window; lower STRAP_Y1_FRAC")
|
||||
|
||||
|
||||
def _convex_hull_2d(points):
|
||||
"""Andrew's monotone chain; returns CCW hull points (numpy (H,2))."""
|
||||
pts = np.unique(np.round(points, 6), axis=0)
|
||||
order = np.lexsort((pts[:, 1], pts[:, 0]))
|
||||
pts = pts[order]
|
||||
if len(pts) < 3:
|
||||
raise RuntimeError("degenerate footprint for convex hull")
|
||||
|
||||
def cross(o, a, b):
|
||||
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
||||
|
||||
lower = []
|
||||
for p in pts:
|
||||
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
|
||||
lower.pop()
|
||||
lower.append(p)
|
||||
upper = []
|
||||
for p in pts[::-1]:
|
||||
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
|
||||
upper.pop()
|
||||
upper.append(p)
|
||||
return np.array(lower[:-1] + upper[:-1], dtype=np.float64)
|
||||
|
||||
|
||||
def build_soles(shell, skin_pos, lm, margin, embed, drop):
|
||||
"""Append one sole prism per foot; returns (sole_start_index, sole_info).
|
||||
|
||||
Footprint = expanded convex hull of that foot's FULL skin xy (pre-cut
|
||||
snapshot); prism spans z in [skin_min - drop, skin_min + embed].
|
||||
|
||||
FLEX CREASE: each prism is bisected (no material removed) by a Y plane at
|
||||
that side's ball-joint line. Without it the long heel->toe top face
|
||||
interpolates skinning linearly across the whole span while the foot
|
||||
creases sharply at the ball during Sprint/Crouch toe-off — the slab sags
|
||||
below the bend crest and toe skin dips through (QA evidence, wave-2
|
||||
slides). The crease ring picks up ball-blended weights from the
|
||||
nearest-vertex transfer, so the slab hinges where the foot hinges.
|
||||
"""
|
||||
me = shell.data
|
||||
sole_start = len(me.vertices)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
|
||||
info = {}
|
||||
for sign in (+1, -1):
|
||||
if sign > 0:
|
||||
side_pos = skin_pos[skin_pos[:, 0] >= 0.0]
|
||||
else:
|
||||
side_pos = skin_pos[skin_pos[:, 0] < 0.0]
|
||||
if len(side_pos) == 0:
|
||||
raise RuntimeError("no skin verts on one side for sole build")
|
||||
hull = _convex_hull_2d(side_pos[:, :2])
|
||||
centroid = hull.mean(axis=0)
|
||||
d = hull - centroid
|
||||
n = d / np.linalg.norm(d, axis=1, keepdims=True)
|
||||
hull = hull + n * margin
|
||||
z_min = float(side_pos[:, 2].min())
|
||||
z_bot, z_top = z_min - drop, z_min + embed
|
||||
info[sign] = {"z_bot": z_bot, "z_top": z_top}
|
||||
|
||||
bot = [bm.verts.new((p[0], p[1], z_bot)) for p in hull]
|
||||
top = [bm.verts.new((p[0], p[1], z_top)) for p in hull]
|
||||
new_faces = []
|
||||
h = len(hull)
|
||||
for i in range(h):
|
||||
j = (i + 1) % h
|
||||
new_faces.append(bm.faces.new((bot[i], bot[j], top[j], top[i])))
|
||||
new_faces.append(bm.faces.new(top))
|
||||
new_faces.append(bm.faces.new(tuple(reversed(bot))))
|
||||
bmesh.ops.recalc_face_normals(bm, faces=new_faces)
|
||||
|
||||
# Flex crease at the ball line (cut only, keep both sides).
|
||||
crease_verts = set()
|
||||
for f in new_faces:
|
||||
crease_verts.update(f.verts)
|
||||
crease_edges = {e for v in crease_verts for e in v.link_edges}
|
||||
res = bmesh.ops.bisect_plane(
|
||||
bm,
|
||||
geom=list(crease_verts) + list(crease_edges) + new_faces,
|
||||
plane_co=(0.0, lm.sides[sign]["ball_y"], 0.0),
|
||||
plane_no=(0.0, 1.0, 0.0),
|
||||
clear_inner=False, clear_outer=False)
|
||||
cut = sum(1 for g in res["geom_cut"] if isinstance(g, bmesh.types.BMVert))
|
||||
log(f"sole {'L' if sign > 0 else 'R'}: hull {h} pts, "
|
||||
f"z[{z_bot:.4f},{z_top:.4f}], ball crease "
|
||||
f"y={lm.sides[sign]['ball_y']:.4f} ({cut} crease verts)")
|
||||
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"soles appended: verts {sole_start} -> {len(me.vertices)}")
|
||||
return sole_start, info
|
||||
|
||||
|
||||
def transfer_sole_weights(shell, skin_pos, skin_weights, sole_start):
|
||||
"""Nearest-vertex weight transfer (same-side skin snapshot) for sole verts."""
|
||||
me = shell.data
|
||||
left = skin_pos[:, 0] >= 0.0
|
||||
idx_by_side = {+1: np.where(left)[0], -1: np.where(~left)[0]}
|
||||
transferred = 0
|
||||
for vi in range(sole_start, len(me.vertices)):
|
||||
co = me.vertices[vi].co
|
||||
side = +1 if co.x >= 0.0 else -1
|
||||
cand = idx_by_side[side]
|
||||
d2 = ((skin_pos[cand] - np.array([co.x, co.y, co.z])) ** 2).sum(axis=1)
|
||||
src = int(cand[int(np.argmin(d2))])
|
||||
for gi, w in skin_weights[src]:
|
||||
shell.vertex_groups[gi].add([vi], w, 'REPLACE')
|
||||
transferred += 1
|
||||
log(f"sole weights transferred: {transferred} verts (nearest skin vert)")
|
||||
|
||||
|
||||
def clamp_strap_under_sole(shell, sole_start, sole_info):
|
||||
"""Clamp strap verts that dipped below the sole interior back inside it."""
|
||||
me = shell.data
|
||||
n = 0
|
||||
for vi in range(sole_start):
|
||||
v = me.vertices[vi]
|
||||
side = +1 if v.co.x >= 0.0 else -1
|
||||
floor_z = sole_info[side]["z_bot"] + CLAMP_INSET_M
|
||||
if v.co.z < floor_z:
|
||||
v.co.z = floor_z
|
||||
n += 1
|
||||
me.update()
|
||||
if n:
|
||||
log(f"clamped {n} strap verts above the sole bottom (hidden in slab)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Deterministic region UVs (no bpy.ops dependency)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _dominant_axis(normal):
|
||||
a = (abs(normal.x), abs(normal.y), abs(normal.z))
|
||||
return a.index(max(a))
|
||||
|
||||
|
||||
def author_region_uvs(shell, sole_start):
|
||||
"""Re-UV the garment: strap faces -> left half, sole faces -> right half,
|
||||
each split into three stacked tiles by dominant normal axis (planar
|
||||
projection). Faces within one (region, axis) tile may overlap — harmless,
|
||||
they share one flat colour — but strap/sole texels never mix."""
|
||||
me = shell.data
|
||||
while len(me.uv_layers) > 1:
|
||||
me.uv_layers.remove(me.uv_layers[-1])
|
||||
if len(me.uv_layers) == 0:
|
||||
me.uv_layers.new(name="UVMap")
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.normal_update()
|
||||
uvl = bm.loops.layers.uv[0]
|
||||
|
||||
# region 0 = strap (u 0.02..0.48), region 1 = sole (u 0.52..0.98)
|
||||
u_ranges = {0: (0.02, 0.48), 1: (0.52, 0.98)}
|
||||
proj = {0: (1, 2), 1: (0, 2), 2: (0, 1)} # axis -> (coord_a, coord_b)
|
||||
|
||||
buckets = {}
|
||||
for face in bm.faces:
|
||||
region = 1 if all(v.index >= sole_start for v in face.verts) else 0
|
||||
axis = _dominant_axis(face.normal)
|
||||
buckets.setdefault((region, axis), []).append(face)
|
||||
|
||||
for (region, axis), faces in buckets.items():
|
||||
ca, cb = proj[axis]
|
||||
pts = []
|
||||
for f in faces:
|
||||
for lo in f.loops:
|
||||
pts.append((lo.vert.co[ca], lo.vert.co[cb]))
|
||||
pts = np.array(pts)
|
||||
lo_a, hi_a = float(pts[:, 0].min()), float(pts[:, 0].max())
|
||||
lo_b, hi_b = float(pts[:, 1].min()), float(pts[:, 1].max())
|
||||
da = max(hi_a - lo_a, 1e-6)
|
||||
db = max(hi_b - lo_b, 1e-6)
|
||||
u0, u1 = u_ranges[region]
|
||||
v0 = 0.02 + axis * (1.0 / 3.0)
|
||||
v1 = v0 + (1.0 / 3.0) - 0.04
|
||||
for f in faces:
|
||||
for lo in f.loops:
|
||||
a = (lo.vert.co[ca] - lo_a) / da
|
||||
b = (lo.vert.co[cb] - lo_b) / db
|
||||
lo[uvl].uv = (u0 + a * (u1 - u0), v0 + b * (v1 - v0))
|
||||
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"region UVs authored: {len(buckets)} (region,axis) tiles")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Paint albedo + mask (per-face flat colours, shared rasterization)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _raster_tri_multi(bufs_colors, a, b, c, W, H):
|
||||
"""Barycentric fill of one UV triangle into several (buf, color) pairs."""
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
px = xs + 0.5
|
||||
py = ys + 0.5
|
||||
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
|
||||
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
for buf, color in bufs_colors:
|
||||
region = buf[miny:maxy + 1, minx:maxx + 1]
|
||||
region[inside] = np.array(color, dtype=buf.dtype)
|
||||
|
||||
|
||||
def _dilate_painted(alb, mask, flag, iters):
|
||||
"""Grow painted texels into the background so bilinear/mip bleed at tile
|
||||
edges picks up real region colours, not the background fill."""
|
||||
H, W = flag.shape
|
||||
for _ in range(iters):
|
||||
grew = np.zeros_like(flag)
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
src = np.zeros_like(flag)
|
||||
sy0, sy1 = max(dy, 0), H + min(dy, 0)
|
||||
ty0, ty1 = max(-dy, 0), H + min(-dy, 0)
|
||||
sx0, sx1 = max(dx, 0), W + min(dx, 0)
|
||||
tx0, tx1 = max(-dx, 0), W + min(-dx, 0)
|
||||
src[ty0:ty1, tx0:tx1] = flag[sy0:sy1, sx0:sx1]
|
||||
fill = (~flag) & (~grew) & src
|
||||
if not fill.any():
|
||||
continue
|
||||
alb_src = np.zeros_like(alb)
|
||||
alb_src[ty0:ty1, tx0:tx1] = alb[sy0:sy1, sx0:sx1]
|
||||
mask_src = np.zeros_like(mask)
|
||||
mask_src[ty0:ty1, tx0:tx1] = mask[sy0:sy1, sx0:sx1]
|
||||
alb[fill] = alb_src[fill]
|
||||
mask[fill] = mask_src[fill]
|
||||
grew |= fill
|
||||
flag |= grew
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, sole_start, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once: sole -> R + sole tone, strap -> G +
|
||||
strap tone. One classification drives both outputs (denim practice)."""
|
||||
W = H = TEX_SIZE
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = STRAP_RGB[c]
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = strap green (bleed-safe default)
|
||||
flag_buf = np.zeros((H, W), dtype=bool)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
uvl = bm.loops.layers.uv[0]
|
||||
|
||||
counts = {"sole": 0, "strap": 0}
|
||||
for face in bm.faces:
|
||||
is_sole = all(v.index >= sole_start for v in face.verts)
|
||||
counts["sole" if is_sole else "strap"] += 1
|
||||
alb_rgb = SOLE_RGB if is_sole else STRAP_RGB
|
||||
mask_rgba = (1.0, 0.0, 0.0, 0.0) if is_sole else (0.0, 1.0, 0.0, 0.0)
|
||||
alb_rgba = (alb_rgb[0], alb_rgb[1], alb_rgb[2], 1.0)
|
||||
uvs = [lo[uvl].uv.copy() for lo in face.loops]
|
||||
flag_view = flag_buf[:, :, None] # view — writes reach flag_buf
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_multi(
|
||||
[(alb_buf, alb_rgba), (mask_buf, mask_rgba),
|
||||
(flag_view, True)],
|
||||
uvs[0], uvs[i], uvs[i + 1], W, H)
|
||||
bm.free()
|
||||
total = max(sum(counts.values()), 1)
|
||||
log("region faces: " + " ".join(
|
||||
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
|
||||
|
||||
_dilate_painted(alb_buf, mask_buf, flag_buf, DILATE_PX)
|
||||
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise = ((rng.random((H, W, 1), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
alb_buf[:, :, :3] = np.clip(alb_buf[:, :, :3] + noise, 0.0, 1.0)
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"slides_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"slides_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
# The glTF exporter names the embedded image after the file basename, and
|
||||
# the Godot import EXTRACTS it as <glb>_<imagename>.png. Point the image at
|
||||
# the shared base_albedo.png so the extraction lands exactly on the sidecar
|
||||
# saved above (<body>_base_albedo.png, same pixels — hoodie/tshirt
|
||||
# convention), instead of a doubled <body>_<body>_base_albedo.png whose
|
||||
# deletion would break the imported scene. The shared file is re-pointed
|
||||
# to the reference body's paint at the end of the run (main()).
|
||||
albedo_img.filepath_raw = os.path.join(os.path.dirname(albedo_path),
|
||||
"base_albedo.png")
|
||||
albedo_img.save()
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_slides(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell) # merge UV-seam/segment duplicate verts
|
||||
|
||||
lm = FootLandmarks(armature)
|
||||
skin_pos, skin_weights = snapshot_skin(shell)
|
||||
|
||||
strap_cut(shell, lm)
|
||||
check_strap_rims(shell, lm)
|
||||
base.offset_outward(shell, offset)
|
||||
base.solidify(shell, STRAP_THICKNESS_M)
|
||||
|
||||
sole_start, sole_info = build_soles(
|
||||
shell, skin_pos, lm,
|
||||
SOLE_MARGIN_M * lm.scale, SOLE_EMBED_M * lm.scale,
|
||||
SOLE_DROP_M * lm.scale)
|
||||
transfer_sole_weights(shell, skin_pos, skin_weights, sole_start)
|
||||
clamp_strap_under_sole(shell, sole_start, sole_info)
|
||||
|
||||
author_region_uvs(shell, sole_start)
|
||||
denim.author_parked_uv2(shell) # slides are not logo-capable
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, sole_start, albedo_path,
|
||||
mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global STRAP_Y0_FRAC, STRAP_Y1_FRAC, STRAP_RGB, SOLE_RGB
|
||||
global SOLE_MARGIN_M, SOLE_EMBED_M, SOLE_DROP_M, NOISE_SEED
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--strap-y0 F] [--strap-y1 F] [--sole-margin M] "
|
||||
"[--sole-embed M] [--sole-drop M] [--strap-rgb r,g,b] "
|
||||
"[--sole-rgb r,g,b] [--seed N]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
|
||||
def _f(flag, default):
|
||||
return float(argv[argv.index(flag) + 1]) if flag in argv else default
|
||||
|
||||
def _rgb(flag, default):
|
||||
if flag not in argv:
|
||||
return default
|
||||
return tuple(float(v) for v in argv[argv.index(flag) + 1].split(","))
|
||||
|
||||
offset = _f("--offset", STRAP_OFFSET_M)
|
||||
STRAP_Y0_FRAC = _f("--strap-y0", STRAP_Y0_FRAC)
|
||||
STRAP_Y1_FRAC = _f("--strap-y1", STRAP_Y1_FRAC)
|
||||
SOLE_MARGIN_M = _f("--sole-margin", SOLE_MARGIN_M)
|
||||
SOLE_EMBED_M = _f("--sole-embed", SOLE_EMBED_M)
|
||||
SOLE_DROP_M = _f("--sole-drop", SOLE_DROP_M)
|
||||
NOISE_SEED = int(_f("--seed", NOISE_SEED))
|
||||
STRAP_RGB = _rgb("--strap-rgb", STRAP_RGB)
|
||||
SOLE_RGB = _rgb("--sole-rgb", SOLE_RGB)
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"slides per-body mode: {len(bodies)} bodies, strap offset "
|
||||
f"{offset * 1000:.0f} mm, band frac [{STRAP_Y0_FRAC},{STRAP_Y1_FRAC}]")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_slides(body_dir, out_dir, body, offset)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,753 @@
|
||||
"""
|
||||
blender_author_sneakers.py (T-1089 wave 2, sneakers_modern + trainer family)
|
||||
|
||||
Authors low-top TRAINERS as per-body offset shells over BOTH feet (seg_foot_l +
|
||||
seg_foot_r joined into ONE garment — the peasant_shoes both-feet convention),
|
||||
reusing blender_author_offset_shell.py as a library (scene build, join, offset,
|
||||
solidify, GLB export) and blender_author_denim_pants.py's boundary weld.
|
||||
|
||||
What footwear needs beyond the bottoms family, as reusable parameters:
|
||||
|
||||
* boundary WELD first (denim practice) — the raw foot segment is NOT one
|
||||
surface: the sole is a separate coincident-vert patch and the ankle cut
|
||||
leaves floating shards (probe: average_m = 4 islands, muscular_m/child = 8).
|
||||
remove_doubles at 0.5 mm fuses everything into one watertight-except-ankle
|
||||
shell per foot; weights identical by origin, so skinning is unaffected.
|
||||
* COLLAR cut + rim flatten — the segment splitter's ankle boundary is jagged
|
||||
weight-threshold teeth (3.3-8.3 cm across bodies, back-biased). Verts above
|
||||
the collar plane (a fraction of the ankle-joint height) are deleted, then
|
||||
every remaining boundary vert is pulled ONTO the collar plane — a clean
|
||||
horizontal low-top opening. Re-flattened after the outward offset because
|
||||
rim-vert normals have a +z bias that would lift the rim.
|
||||
* SOLE slab (this family's new offset-shell param) — the rim-flatten practice
|
||||
applied to the ground plane, built as cut + flatten + extrude + fill: the
|
||||
shell's underside band is cut away (with the toe-knuckle lobes that survive
|
||||
smoothing), the open rim is flattened onto the cut plane and its outline
|
||||
relaxed, then extruded straight down to a plane `--sole-drop` (scaled per
|
||||
body) below the body's own foot-bottom and closed with a flat bottom — a
|
||||
clean prism slab; Solidify thickens it and a final clamp guarantees the
|
||||
outer sole is planar. (Snapping the band in place instead collapses mesh
|
||||
rows into crumpled slivers — melted-wax scallops on the probe renders.)
|
||||
* TOE-BOX SMOOTHING + ROUNDING — the body feet have INDIVIDUAL TOES; a raw
|
||||
offset shell reads as a foot-shaped slipper (verified on average_m). Heavy
|
||||
iterative vertex smoothing over the toe region (feathered, boundary rim
|
||||
pinned) melts the toe creases into one volume, a light global pass
|
||||
de-lumps the ankle anatomy, then an extra normal-along inflation feathered
|
||||
toward the toe tip restores the lost volume as a rounded sneaker toe box.
|
||||
* COLLAR FLARE — small feathered radial stand-off at the opening (the jeans
|
||||
waist-flare practice) for ankle-flex clearance under Walk/Crouch.
|
||||
* TEXEL-level feature painting (denim practice): one analytic field drives
|
||||
BOTH the albedo and the region mask, so they always agree:
|
||||
- albedo: painted lace cross-straps over a darker tongue panel, toe cap
|
||||
+ border line, foxing stripe at the sole top, heel tab, collar band,
|
||||
vamp + heel-counter panel lines (swoosh-free — no brand marks).
|
||||
Everyday default: white/grey, flat toon-friendly tones.
|
||||
- mask: sole -> R, upper -> G, laces + trim (collar band, toe cap,
|
||||
heel tab) -> B (spec: sole=R, upper=G, laces+trim=B).
|
||||
* a parked logo_uv TEXCOORD_1 layer (all UVs at (2,2)) — not logo-capable,
|
||||
but toon_garment.gdshader samples UV2 unconditionally.
|
||||
|
||||
All parameters derive PER BODY from that body's own bone landmarks (foot_l /
|
||||
ball_l) and measured mesh extents (foot length, half-width, ground plane),
|
||||
scaled by foot length against the hand-calibrated average_m reference — the
|
||||
same proportional-ratio philosophy as base.derive_thresholds. Per-body mode
|
||||
only (offset shells author per body, Q-060). Left/right feet share body-atlas
|
||||
UV space whose islands may overlap; every painted feature is |x|-mirror
|
||||
symmetric, so overlapping texels agree by construction.
|
||||
|
||||
Usage (sneakers_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_sneakers.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/sneakers_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.009] [--sole-drop 0.015] \
|
||||
[--sole-snap-frac 0.55] [--collar-frac 0.95] [--collar-flare 0.002] \
|
||||
[--toe-round 0.004] [--toe-smooth 12] [--laces 4] [--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module + the denim module (weld utility)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(mod_name, file_name):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
mod_name, os.path.join(_HERE, file_name))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load("offset_shell_base", "blender_author_offset_shell.py")
|
||||
denim = _load("denim_pants_lib", "blender_author_denim_pants.py")
|
||||
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (toes point -Y)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (CLI-overridable ones are module globals)
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = ["seg_foot_l", "seg_foot_r"]
|
||||
|
||||
OFFSET_M = 0.009 # shoe standoff — snugger than cloth (12 mm)
|
||||
SOLE_DROP_M = 0.015 # sole slab depth below the body's own foot bottom
|
||||
# (scaled by foot length per body)
|
||||
SOLE_SNAP_FRAC = 0.55 # sole CUT height as a fraction of the sole rise
|
||||
# (ground -> foxing top): everything below is cut
|
||||
# away and rebuilt as an extruded prism slab (see
|
||||
# build_sole_slab) — kills the toe-knuckle underside
|
||||
# lobes that survive smoothing (probe, average_m)
|
||||
COLLAR_FRAC = 0.95 # collar plane as fraction of ankle-joint height
|
||||
COLLAR_FLARE_M = 0.002 # radial stand-off at the opening (ankle-flex room)
|
||||
SMOOTH_GLOBAL_ITERS = 2 # light instep/ankle de-lumping passes (behind ball;
|
||||
# the toe box is built analytically, not smoothed)
|
||||
SMOOTH_FACTOR = 0.5
|
||||
|
||||
# Convex toe box (shared base.convex_toe_box) — rounded, roomy trainer cap.
|
||||
TOE_EXT_M = 0.012 # nose extension past the longest toe (m)
|
||||
TOE_WMARGIN_M = 0.006 # half-width padding around the widest toe (roomy)
|
||||
TOE_HCLEAR_M = 0.010 # vertical headroom above the toes (flex room)
|
||||
TOE_FEATHER_M = 0.022 # blend band behind the ball
|
||||
N_LACES = 4 # painted cross-straps
|
||||
PLAIN = False # --plain: flat upper, no painted features
|
||||
TEX_SIZE = 1024 # painted lace/panel lines need > 512
|
||||
NOISE_SEED = 3089
|
||||
|
||||
# Vertical proportions — fractions of the collar height above the ground.
|
||||
SOLE_TOP_FRAC = 0.30 # sole sidewall (foxing) top -> R region below this
|
||||
COLLAR_BAND_FRAC = 0.15 # collar trim band height (B region)
|
||||
VAMP_LINE_FRAC = 0.42 # side panel line height between sole top and collar
|
||||
|
||||
# Foot-axis proportions — fractions of foot length / half-width.
|
||||
TOE_CAP_FRAC = 0.18 # toe cap depth from the toe tip (B region)
|
||||
HEEL_LINE_FRAC = 0.22 # heel-counter panel line from the heel tip
|
||||
LACE_T0, LACE_T1 = 0.18, 0.80 # lace panel span along the foot bone
|
||||
LACE_HALFW_FRAC = 0.40 # lace panel half-width, of foot half-width
|
||||
LACE_STRIPE_DUTY = 0.44 # stripe thickness as a fraction of stripe spacing
|
||||
LINE_W_M = 0.0035 # painted panel/border line half-width (scaled)
|
||||
HEEL_TAB_HALFW_M = 0.012 # heel tab half-width (scaled)
|
||||
|
||||
# Everyday default: white/grey, flat toon-friendly tones (sRGB floats).
|
||||
UPPER_RGB = (0.880, 0.880, 0.890)
|
||||
SOLE_RGB = (0.780, 0.790, 0.800)
|
||||
TREAD_RGB = (0.450, 0.460, 0.480) # below-ground outsole
|
||||
TOE_RGB = (0.920, 0.920, 0.930) # toe bumper
|
||||
LACE_RGB = (0.960, 0.960, 0.965)
|
||||
TONGUE_SHADE = 0.90 # lace-zone panel behind the straps
|
||||
COLLAR_SHADE = 0.88 # collar band darkening
|
||||
HEEL_TAB_SHADE = 0.72
|
||||
LINE_SHADE = 0.74 # painted panel/border lines
|
||||
ALBEDO_NOISE = 0.015
|
||||
|
||||
# Reference proportions (average_m) the fractions were calibrated against.
|
||||
_REF_FOOT_LEN = 0.2704 # heel y (0.1374) - toe tip y (-0.1330)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FootLandmarks:
|
||||
"""Cut/mask/paint parameters from one body's foot bones + measured mesh."""
|
||||
|
||||
def __init__(self, armature, shell):
|
||||
bones = armature.data.bones
|
||||
foot = bones.get("foot_l")
|
||||
ball = bones.get("ball_l")
|
||||
if foot is None or ball is None:
|
||||
raise RuntimeError("foot_l/ball_l missing — not the 65-bone rig?")
|
||||
# Bone landmarks (left foot; the right mirrors via |x|).
|
||||
self.ankle_y = foot.head_local.y
|
||||
self.ankle_z = foot.head_local.z
|
||||
self.ball_y = foot.tail_local.y
|
||||
self.ball_z = foot.tail_local.z
|
||||
self.toe_y = ball.tail_local.y
|
||||
# Measured mesh extents (left-foot verts; feet are x-mirror symmetric).
|
||||
lx = [v.co.x for v in shell.data.vertices if v.co.x > 0.0]
|
||||
ly = [v.co.y for v in shell.data.vertices if v.co.x > 0.0]
|
||||
zs = [v.co.z for v in shell.data.vertices]
|
||||
self.foot_cx = (min(lx) + max(lx)) / 2.0
|
||||
self.half_w = (max(lx) - min(lx)) / 2.0
|
||||
self.heel_y = max(ly)
|
||||
self.toe_tip_y = min(ly)
|
||||
self.ground_z = min(zs)
|
||||
self.foot_len = self.heel_y - self.toe_tip_y
|
||||
self.s = self.foot_len / _REF_FOOT_LEN
|
||||
|
||||
self.collar_z = self.ground_z + COLLAR_FRAC * (self.ankle_z - self.ground_z)
|
||||
self.sole_drop = SOLE_DROP_M * self.s
|
||||
self.sole_bottom = self.ground_z - self.sole_drop
|
||||
rise = self.collar_z - self.ground_z
|
||||
self.sole_top = self.ground_z + SOLE_TOP_FRAC * rise
|
||||
self.band_h = COLLAR_BAND_FRAC * rise
|
||||
self.vamp_z = self.sole_top + VAMP_LINE_FRAC * (self.collar_z - self.sole_top)
|
||||
self.cap_y = self.toe_tip_y + TOE_CAP_FRAC * self.foot_len
|
||||
self.heel_line_y = self.heel_y - HEEL_LINE_FRAC * self.foot_len
|
||||
self.lace_halfw = LACE_HALFW_FRAC * self.half_w
|
||||
self.line_w = LINE_W_M * self.s
|
||||
log(f"landmarks: ankle_z={self.ankle_z:.4f} collar_z={self.collar_z:.4f} "
|
||||
f"ground={self.ground_z:.4f} sole_bottom={self.sole_bottom:.4f} "
|
||||
f"sole_top={self.sole_top:.4f} foot_len={self.foot_len:.4f} "
|
||||
f"half_w={self.half_w:.4f} cx={self.foot_cx:.4f} s={self.s:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: collar cut + flatten, sole slab, toe round, collar flare
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def make_normals_consistent(shell):
|
||||
"""Outward-consistent normals BEFORE the offset — the raw foot's sole patch
|
||||
winds independently of the upper, so post-weld normals need one recalc or
|
||||
the offset would pull the sole inward."""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
shell.select_set(True)
|
||||
bpy.context.view_layer.objects.active = shell
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.normals_make_consistent(inside=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
log("recalculated outward-consistent normals")
|
||||
|
||||
|
||||
def collar_cut(shell, collar_z):
|
||||
"""Delete verts above the collar plane (bone-plane-cut practice)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts if v.co.z > collar_z]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"collar cut at z={collar_z:.4f}: removed {len(doomed)} verts")
|
||||
|
||||
|
||||
def flatten_collar_rims(shell, collar_z, label):
|
||||
"""Pull every open-boundary vert ONTO the collar plane (rim-flatten
|
||||
practice). After the weld + collar cut the only open boundary is the two
|
||||
collar rims, so a single pass flattens both feet."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
if not boundary:
|
||||
bm.free()
|
||||
raise RuntimeError("no open collar boundary found after cut")
|
||||
zs = [bm.verts[i].co.z for i in boundary]
|
||||
for i in boundary:
|
||||
bm.verts[i].co.z = collar_z
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened collar rims ({label}): {len(boundary)} verts, "
|
||||
f"z {min(zs):.4f}..{max(zs):.4f} -> {collar_z:.4f}")
|
||||
|
||||
|
||||
def smooth_shell(shell, lm):
|
||||
"""De-lump the instep/ankle anatomy only (BEHIND the ball joint): light
|
||||
iterative vertex smoothing with the open collar boundary pinned. The toe
|
||||
box itself is built analytically by base.convex_toe_box, so the toe zone
|
||||
(forward of the ball) is deliberately excluded here — smoothing it would
|
||||
shrink the toes the toe box must still enclose."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
ball_u = lm.ball_y * FRONT_Y_SIGN
|
||||
# interior verts BEHIND the ball joint (u < ball_u): instep, arch, heel.
|
||||
heel = [v for v in bm.verts if v.index not in boundary
|
||||
and (v.co.y * FRONT_Y_SIGN) < ball_u]
|
||||
for _ in range(SMOOTH_GLOBAL_ITERS):
|
||||
bmesh.ops.smooth_vert(bm, verts=heel, factor=SMOOTH_FACTOR,
|
||||
use_axis_x=True, use_axis_y=True, use_axis_z=True)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"smoothed instep/heel: {SMOOTH_GLOBAL_ITERS} passes "
|
||||
f"({len(heel)} verts behind ball u<{ball_u:.4f})")
|
||||
|
||||
|
||||
RIM_RELAX_ITERS = 3 # along-ring XY relaxation of the cut rim outline
|
||||
|
||||
|
||||
def build_sole_slab(shell, lm):
|
||||
"""Rim-flatten practice applied to the GROUND plane, done PROPERLY as a
|
||||
cut + flatten + extrude (snapping a whole z-band onto the plane collapses
|
||||
multiple mesh rows into crumpled slivers that read as melted-wax scallops
|
||||
— probe renders v3-v5):
|
||||
|
||||
1. DELETE everything below the cut height (SOLE_SNAP_FRAC of the sole
|
||||
rise) — removes the toe-knuckle underside lobes outright.
|
||||
2. RIM-FLATTEN the resulting open bottom boundary onto the cut plane
|
||||
(exactly the denim ankle practice), then relax the ring outline in
|
||||
XY along the ring only — a smooth footprint curve, rounded toe.
|
||||
3. EXTRUDE the ring straight down to the sole plane — a clean vertical
|
||||
prism wall (extruded verts inherit the rim verts' deform weights,
|
||||
so the sole still flexes at the ball joint).
|
||||
4. FILL the bottom ring with faces — a closed flat underside.
|
||||
|
||||
Solidify then grows the outer surface CLOTH_THICKNESS_M further down,
|
||||
landing the visible outsole on lm.sole_bottom (guaranteed by
|
||||
clamp_residue after solidify)."""
|
||||
plane = lm.sole_bottom + base.CLOTH_THICKNESS_M
|
||||
hi = lm.ground_z + SOLE_SNAP_FRAC * (lm.sole_top - lm.ground_z)
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
|
||||
# 1. cut
|
||||
doomed = [v for v in bm.verts if v.co.z < hi]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
|
||||
# 2. rim-flatten (the collar rim is also open — split boundaries by z)
|
||||
boundary = {v for e in bm.edges if len(e.link_faces) == 1
|
||||
for v in e.verts}
|
||||
z_mid = (lm.collar_z + hi) / 2.0
|
||||
sole_rim = {v for v in boundary if v.co.z < z_mid}
|
||||
if not sole_rim:
|
||||
bm.free()
|
||||
raise RuntimeError("no sole rim found after cut — check cut height")
|
||||
for v in sole_rim:
|
||||
v.co.z = hi
|
||||
# along-ring XY relax: average each rim vert with its ring neighbours
|
||||
# only (bmesh smooth_vert would pull toward the upper rows and shrink)
|
||||
for _ in range(RIM_RELAX_ITERS):
|
||||
new_pos = {}
|
||||
for v in sole_rim:
|
||||
ring_nbrs = [e.other_vert(v) for e in v.link_edges
|
||||
if len(e.link_faces) == 1
|
||||
and e.other_vert(v) in sole_rim]
|
||||
if len(ring_nbrs) >= 2:
|
||||
ax = sum(n.co.x for n in ring_nbrs) / len(ring_nbrs)
|
||||
ay = sum(n.co.y for n in ring_nbrs) / len(ring_nbrs)
|
||||
new_pos[v] = (v.co.x + 0.5 * (ax - v.co.x),
|
||||
v.co.y + 0.5 * (ay - v.co.y))
|
||||
for v, (x, y) in new_pos.items():
|
||||
v.co.x = x
|
||||
v.co.y = y
|
||||
|
||||
# 3. extrude the rim edges straight down to the sole plane
|
||||
rim_edges = [e for e in bm.edges if len(e.link_faces) == 1
|
||||
and e.verts[0] in sole_rim and e.verts[1] in sole_rim]
|
||||
ret = bmesh.ops.extrude_edge_only(bm, edges=rim_edges)
|
||||
new_verts = [g for g in ret["geom"]
|
||||
if isinstance(g, bmesh.types.BMVert)]
|
||||
for v in new_verts:
|
||||
v.co.z = plane
|
||||
|
||||
# 4. close the bottom
|
||||
bottom_edges = [e for e in bm.edges if len(e.link_faces) == 1
|
||||
and all(abs(v.co.z - plane) < 1e-6 for v in e.verts)]
|
||||
filled = bmesh.ops.holes_fill(bm, edges=bottom_edges, sides=0)
|
||||
|
||||
# 5. UV-park the new faces. The fill n-gon's default loop UVs span the
|
||||
# whole enclosed UV region — its texel bake overwrites painted islands
|
||||
# (probe v6: mottled patches, features erased); wall quads' copied UVs
|
||||
# sit ON the island boundary where bilinear sampling picks up
|
||||
# background. ONE park point per foot (per-face points sample texels of
|
||||
# varying paint state and stripe the wall — probe v7): all new faces on
|
||||
# a side park on the UV centre of that side's LARGEST-UV-area
|
||||
# rim-adjacent face — big enough that the bake reliably rasterizes its
|
||||
# interior, and its centre lies in the sole band (z <= sole_top), so
|
||||
# the sampled texel is the flat sole tone with an R-region mask.
|
||||
uv_layer = bm.loops.layers.uv[0] if len(bm.loops.layers.uv) else None
|
||||
if uv_layer is not None:
|
||||
new_faces = [g for g in ret["geom"]
|
||||
if isinstance(g, bmesh.types.BMFace)]
|
||||
new_faces += list(filled["faces"])
|
||||
new_face_set = set(new_faces)
|
||||
best = {} # x-sign side -> (uv_area, centre uv); feet never cross x=0
|
||||
for v in sole_rim:
|
||||
side = 1 if v.co.x >= 0.0 else -1
|
||||
for loop in v.link_loops:
|
||||
f = loop.face
|
||||
if f in new_face_set:
|
||||
continue
|
||||
us = [lp[uv_layer].uv for lp in f.loops]
|
||||
area = 0.0
|
||||
for i in range(len(us)):
|
||||
j = (i + 1) % len(us)
|
||||
area += us[i].x * us[j].y - us[j].x * us[i].y
|
||||
area = abs(area) * 0.5
|
||||
if side not in best or area > best[side][0]:
|
||||
best[side] = (area,
|
||||
(sum(u.x for u in us) / len(us),
|
||||
sum(u.y for u in us) / len(us)))
|
||||
for f in new_faces:
|
||||
side = 1 if sum(v.co.x for v in f.verts) >= 0.0 else -1
|
||||
if side not in best:
|
||||
side = -side
|
||||
uv = best[side][1]
|
||||
for loop in f.loops:
|
||||
loop[uv_layer].uv = uv
|
||||
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"sole slab: cut {len(doomed)} verts (z < {hi:.4f}), rim "
|
||||
f"{len(sole_rim)} verts -> z={hi:.4f}, extruded {len(new_verts)} "
|
||||
f"verts -> z={plane:.4f}, filled {len(filled['faces'])} bottom faces")
|
||||
|
||||
|
||||
def collar_flare(shell, lm, flare):
|
||||
"""Feathered radial stand-off at the opening (waist-flare practice) —
|
||||
ankle-flex clearance under Walk/Crouch."""
|
||||
if flare <= 0.0:
|
||||
return
|
||||
z0 = lm.collar_z - 2.0 * lm.band_h
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.normal_update()
|
||||
n = 0
|
||||
for v in bm.verts:
|
||||
if v.co.z > z0:
|
||||
t = min((v.co.z - z0) / (2.0 * lm.band_h), 1.0)
|
||||
nx, ny = v.normal.x, v.normal.y
|
||||
mag = (nx * nx + ny * ny) ** 0.5
|
||||
if mag > 1e-6:
|
||||
v.co.x += flare * t * nx / mag
|
||||
v.co.y += flare * t * ny / mag
|
||||
n += 1
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"collar flare: {n} verts, +{flare * 1000:.1f} mm radial at rim")
|
||||
|
||||
|
||||
def clamp_residue(shell, lm):
|
||||
"""Post-solidify safety clamps: the rim cap can push verts above the
|
||||
collar plane, and blended edge normals leave the outsole slightly uneven —
|
||||
squash both back onto their planes."""
|
||||
me = shell.data
|
||||
n_top = n_bot = 0
|
||||
for v in me.vertices:
|
||||
if v.co.z > lm.collar_z:
|
||||
v.co.z = lm.collar_z
|
||||
n_top += 1
|
||||
elif v.co.z < lm.sole_bottom:
|
||||
v.co.z = lm.sole_bottom
|
||||
n_bot += 1
|
||||
me.update()
|
||||
log(f"clamped residue: {n_top} collar verts -> {lm.collar_z:.4f}, "
|
||||
f"{n_bot} sole verts -> {lm.sole_bottom:.4f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sneaker feature field (texel-level; drives albedo AND mask together).
|
||||
# Every feature is |x|-mirror symmetric so the (possibly overlapping)
|
||||
# left/right UV islands paint identical values.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
dx = px - side * lm.foot_cx # signed offset from the foot centre
|
||||
adx = np.abs(dx)
|
||||
|
||||
in_sole = pz <= lm.sole_top
|
||||
# Tread = the flat underside plane only (never visible from the side).
|
||||
# The sole SIDE WALL is snapped geometry whose UV triangles are stretched
|
||||
# slivers — any tonal variation there (noise, a dark tread band) smears
|
||||
# into vertical streaks under bilinear magnification, so the whole band
|
||||
# above the underside is painted ONE flat tone.
|
||||
in_tread = pz <= lm.sole_bottom + 0.0015
|
||||
in_band = (pz >= lm.collar_z - lm.band_h) & ~in_sole
|
||||
|
||||
# Lace panel: param t along the foot bone (ankle head -> ball tail) in
|
||||
# the (y,z) plane; texels above the bone line, near the centreline.
|
||||
dy_ax = lm.ball_y - lm.ankle_y
|
||||
dz_ax = lm.ball_z - lm.ankle_z
|
||||
l2 = dy_ax * dy_ax + dz_ax * dz_ax
|
||||
t = ((py - lm.ankle_y) * dy_ax + (pz - lm.ankle_z) * dz_ax) / l2
|
||||
above_bone = pz > (lm.ankle_z + t * dz_ax + 0.002 * lm.s)
|
||||
in_tongue = (~in_sole & above_bone & (adx < lm.lace_halfw)
|
||||
& (t >= LACE_T0) & (t <= LACE_T1))
|
||||
frac = (t - LACE_T0) / (LACE_T1 - LACE_T0)
|
||||
stripe_pos = frac * N_LACES
|
||||
stripe_d = np.abs(stripe_pos - (np.floor(stripe_pos) + 0.5))
|
||||
laces = in_tongue & (stripe_d < 0.5 * LACE_STRIPE_DUTY)
|
||||
|
||||
toe_cap = (py < lm.cap_y) & ~in_sole
|
||||
heel_tab = ((adx < HEEL_TAB_HALFW_M * lm.s) & ~in_sole
|
||||
& (py > lm.ankle_y + 0.55 * (lm.heel_y - lm.ankle_y)))
|
||||
|
||||
# --- albedo -------------------------------------------------------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = UPPER_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
for c in range(3):
|
||||
alb[in_sole, c] = SOLE_RGB[c] # flat, noise-free (sliver UVs)
|
||||
alb[in_tread, c] = TREAD_RGB[c] # underside plane only
|
||||
|
||||
if not PLAIN:
|
||||
for c in range(3):
|
||||
alb[toe_cap & ~in_sole, c] = TOE_RGB[c] + noise[toe_cap & ~in_sole]
|
||||
alb[in_tongue, :3] *= TONGUE_SHADE
|
||||
for c in range(3):
|
||||
alb[laces, c] = LACE_RGB[c] + noise[laces]
|
||||
alb[in_band & ~laces, :3] *= COLLAR_SHADE
|
||||
alb[heel_tab & ~in_band, :3] *= HEEL_TAB_SHADE
|
||||
|
||||
# Painted panel lines (albedo only — swoosh-free).
|
||||
foxing = np.abs(pz - lm.sole_top) < lm.line_w
|
||||
cap_border = (np.abs(py - lm.cap_y) < lm.line_w) & ~in_sole
|
||||
vamp = ((np.abs(pz - lm.vamp_z) < lm.line_w) & ~in_sole
|
||||
& (adx > lm.lace_halfw * 0.8)
|
||||
& (py > lm.cap_y) & (py < lm.heel_line_y))
|
||||
heel_ctr = ((np.abs(py - lm.heel_line_y) < lm.line_w) & ~in_sole
|
||||
& (pz < lm.collar_z - lm.band_h))
|
||||
lines = (foxing | cap_border | vamp | heel_ctr) & ~laces
|
||||
alb[lines, :3] *= LINE_SHADE
|
||||
|
||||
# --- region mask: sole R / upper G / laces+trim B -------------------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_b = (laces | toe_cap | heel_tab | in_band) & ~in_sole
|
||||
mask[in_sole, 0] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[~(in_sole | is_b), 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the sneaker field, write albedo + mask together."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = UPPER_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = upper green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
counts = [float(mask_buf[:, :, c].sum()) for c in range(3)]
|
||||
total = max(sum(counts), 1.0)
|
||||
log(f"painted {tri_count} UV triangles ({W}x{H}); mask texels "
|
||||
f"R={100 * counts[0] / total:.1f}% G={100 * counts[1] / total:.1f}% "
|
||||
f"B={100 * counts[2] / total:.1f}%")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"sneaker_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"sneaker_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_sneaker_shell(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell) # fuse sole patch + ankle shards
|
||||
make_normals_consistent(shell)
|
||||
|
||||
lm = FootLandmarks(armature, shell)
|
||||
|
||||
collar_cut(shell, lm.collar_z)
|
||||
# Convex toe box FIRST, on the raw skin foot (so it encloses the real
|
||||
# toes), then de-lump only behind the ball.
|
||||
base.convex_toe_box(
|
||||
shell, armature,
|
||||
extension=TOE_EXT_M * lm.s, width_margin=TOE_WMARGIN_M * lm.s,
|
||||
height_clear=TOE_HCLEAR_M * lm.s, feather_m=TOE_FEATHER_M * lm.s)
|
||||
smooth_shell(shell, lm)
|
||||
flatten_collar_rims(shell, lm.collar_z, "pre-offset")
|
||||
base.offset_outward(shell, offset)
|
||||
flatten_collar_rims(shell, lm.collar_z, "post-offset") # rim normals lift it
|
||||
build_sole_slab(shell, lm)
|
||||
collar_flare(shell, lm, COLLAR_FLARE_M * lm.s)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
clamp_residue(shell, lm)
|
||||
|
||||
denim.author_parked_uv2(shell) # not logo-capable; shader needs UV2
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global OFFSET_M, SOLE_DROP_M, COLLAR_FRAC, COLLAR_FLARE_M
|
||||
global N_LACES, PLAIN, SOLE_SNAP_FRAC
|
||||
global TOE_EXT_M, TOE_WMARGIN_M, TOE_HCLEAR_M
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--sole-drop M] [--sole-snap-frac F] "
|
||||
"[--collar-frac F] [--collar-flare M] [--toe-ext M] "
|
||||
"[--toe-wmargin M] [--toe-hclear M] "
|
||||
"[--laces N] [--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
if "--offset" in argv:
|
||||
OFFSET_M = float(argv[argv.index("--offset") + 1])
|
||||
if "--sole-drop" in argv:
|
||||
SOLE_DROP_M = float(argv[argv.index("--sole-drop") + 1])
|
||||
if "--sole-snap-frac" in argv:
|
||||
SOLE_SNAP_FRAC = float(argv[argv.index("--sole-snap-frac") + 1])
|
||||
if "--collar-frac" in argv:
|
||||
COLLAR_FRAC = float(argv[argv.index("--collar-frac") + 1])
|
||||
if "--collar-flare" in argv:
|
||||
COLLAR_FLARE_M = float(argv[argv.index("--collar-flare") + 1])
|
||||
if "--toe-ext" in argv:
|
||||
TOE_EXT_M = float(argv[argv.index("--toe-ext") + 1])
|
||||
if "--toe-wmargin" in argv:
|
||||
TOE_WMARGIN_M = float(argv[argv.index("--toe-wmargin") + 1])
|
||||
if "--toe-hclear" in argv:
|
||||
TOE_HCLEAR_M = float(argv[argv.index("--toe-hclear") + 1])
|
||||
if "--laces" in argv:
|
||||
N_LACES = int(argv[argv.index("--laces") + 1])
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"sneaker per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{OFFSET_M * 1000:.0f} mm, sole-drop {SOLE_DROP_M * 1000:.0f} mm, "
|
||||
f"collar-frac {COLLAR_FRAC}, laces {N_LACES}, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_sneaker_shell(body_dir, out_dir, body, OFFSET_M)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,597 @@
|
||||
"""
|
||||
blender_author_sweater.py (T-1089 wave 2 — sweater_modern, per-body offset shell)
|
||||
|
||||
Crew-neck pullover authored per body via the offset-shell route. Imports
|
||||
blender_author_offset_shell.py as the shared library (segment join, bone-ratio
|
||||
thresholds, offset, solidify, logo UV2, GLB export) and
|
||||
blender_author_denim_pants.py for the wave-1-proven boundary practices
|
||||
(coincident segment-seam WELD, seg_hips crotch probe). What the sweater adds,
|
||||
as reusable parameters:
|
||||
|
||||
* coverage: torso + torso_upper + FULL arms (cuffs at the wrist) + the UPPER
|
||||
seg_hips band. Including seg_hips is the hem answer for tops: the natural
|
||||
seg_torso bottom boundary is a 9-14 cm jagged tooth ring (probe evidence,
|
||||
all bodies) and flattening it up/down either opens skin holes (hips skin
|
||||
fails to reach the teeth tops in 2/24 azimuth bins) or invents cloth off
|
||||
the body surface. Instead the shell continues into real hip geometry and
|
||||
is hem-CUT at a clean plane derived from bone landmarks (spine_01 ->
|
||||
crotch fraction), then the cut ring is flattened onto that plane
|
||||
(denim-pants ankle practice; pull distance ~ one face, not full teeth).
|
||||
Hips stay UNHIDDEN in coverage.json — skin continues under the hem, so no
|
||||
hole is geometrically possible (uniform_utility waist-join precedent).
|
||||
* crew-neck CUT + rim CIRCULARIZATION — the seg_torso_upper neckline is a
|
||||
jagged tooth ring whose VALLEY sits at neck_z + ~0.23*neck_len on every
|
||||
body (probe evidence, 6 bodies) — exactly crew height — while its teeth
|
||||
reach ~72% up the neck. Raising the rim to the teeth max (the first
|
||||
draft) reads as a chin-high mock-neck, not the crew the spec asks. So:
|
||||
delete the neck-tube verts ABOVE the valley within the ring's own
|
||||
measured hd_max * CUT_R_MUL (per-body self-calibrating radius — the
|
||||
neck-trap flare shell probes strictly outside it), then flatten the new
|
||||
boundary onto the valley plane: verts within r_med * CREW_RIM_R_MUL of
|
||||
the neck axis are circularized to the ring median radius (clean crew
|
||||
rim); flare verts beyond only drop to the plane (no radial pinch, which
|
||||
would fold cloth on wide-trap bodies). Skin-hole-safe: seg_neck skin
|
||||
reaches down exactly to the valley (probe), and runtime never hides
|
||||
segments anyway.
|
||||
* wrist rim flattening onto the cut plane (denim ankle practice on the arm
|
||||
axis) — the vert-threshold cut leaves 1-3 cm teeth; the painted ribbed
|
||||
cuff needs a clean edge.
|
||||
* cuff PINCH — per-vert offset scale tapering to CUFF_PINCH at the wrist so
|
||||
the ribbed cuffs read cinched (knit hugs the wrist).
|
||||
* painted knit identity, driven per texel from final 3D positions
|
||||
(buttondown per-pixel bake machinery): ribbed collar / cuffs / hem —
|
||||
azimuthal rib stripes around the neck axis, arm axis, and torso axis —
|
||||
plus border stitch lines. All LUMINANCE detail, so the luma-preserving
|
||||
toon_garment recolor keeps it under any tint. Warm muted default tints
|
||||
live in the manifest entry, not the albedo.
|
||||
* region mask (spec): collar=R (tint_0), body=G (tint_1), cuffs+hem=B
|
||||
(tint_2). A unused. Logo-capable OFF per spec, but the UV2 chest channel
|
||||
is still authored (costs nothing; buttondown precedent).
|
||||
|
||||
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_sweater.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/sweater_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.014] [--hem-drop 0.30] \
|
||||
[--rib-period 0.011] [--base-rgb 0.62,0.585,0.545]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import blender_author_offset_shell as base # noqa: E402
|
||||
import blender_author_denim_pants as denim # noqa: E402 (weld + hips probe)
|
||||
import blender_author_buttondown as bd # noqa: E402 (per-pixel bake helpers)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (the reusable seam — override for cardigan/turtleneck variants)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
GARMENT_ID = "sweater_modern"
|
||||
|
||||
SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
"seg_hips",
|
||||
]
|
||||
|
||||
OFFSET_M = 0.014 # knit-weight standoff (tee 12 / hoodie 16)
|
||||
THICKNESS_M = 0.005 # knit cloth thickness (tee 4 / hoodie 6)
|
||||
WRIST_FRAC = 0.94 # fraction of lowerarm kept — cuffs AT the wrist
|
||||
CUFF_PINCH = 0.75 # offset scale at the wrist rim (ribbing cinches)
|
||||
CUFF_LEN_FRAC = 0.18 # ribbed cuff length, fraction of lowerarm length
|
||||
HEM_DROP_FRAC = 0.30 # hem below the spine_01 waistline, fraction of
|
||||
# (waistline - crotch) — hip-length pullover
|
||||
HEM_BAND_FRAC = 0.085 # ribbed hem band, fraction of (collar_z - hem_z)
|
||||
COLLAR_REACH_FRAC = 1.45 # collar region radial reach, x collar_x_abs
|
||||
CUT_R_MUL = 1.05 # crew cut radius, x the neck ring's measured hd_max
|
||||
CREW_RIM_R_MUL = 1.6 # circularize rim verts within this x ring r_med
|
||||
COLLAR_BAND_FRAC = 0.32 # ribbed collar band height below the crew rim,
|
||||
# x neck_len (~2.5 cm on average_m) — anchored to
|
||||
# the MEASURED rim, not collar_z_min, so the band
|
||||
# reads as a crew rib, not a chest yoke
|
||||
|
||||
# Knit paint (luminance detail; absolute mm scaled by the body's shoulder
|
||||
# ratio so the rib gauge reads identical from child to heavy_m).
|
||||
RIB_PERIOD_M = 0.011 # one rib pair (dark+light) around the band
|
||||
RIB_DARK = 0.85 # dark rib stripe multiplier
|
||||
BAND_MUL = 0.94 # overall band shade vs body knit
|
||||
LINE_MUL = 0.70 # border stitch line multiplier
|
||||
LINE_HALF_M = 0.0022 # border stitch line half-width
|
||||
|
||||
# Warm muted base fabric (default tints in the manifest carry the colour;
|
||||
# the albedo carries luma detail + the untinted fallback tone).
|
||||
FABRIC_RGB = (0.620, 0.585, 0.545)
|
||||
FABRIC_NOISE = 0.030
|
||||
ALBEDO_SEED = 2093
|
||||
|
||||
MASK_SIZE = 512
|
||||
ALBEDO_SIZE = 1024
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[sweater] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Landmarks (all bone-proportional; same philosophy as base.derive_thresholds)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def derive_landmarks(armature, thr, crotch_z):
|
||||
bones = armature.data.bones
|
||||
neck = bones.get("neck_01")
|
||||
la_l = bones.get("lowerarm_l")
|
||||
la_r = bones.get("lowerarm_r")
|
||||
spine01 = bones.get("spine_01")
|
||||
ua_l = bones.get("upperarm_l")
|
||||
ua_r = bones.get("upperarm_r")
|
||||
if not all([neck, la_l, la_r, spine01, ua_l, ua_r]):
|
||||
raise RuntimeError("landmark bones missing (neck_01/lowerarm/spine_01)")
|
||||
|
||||
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
s = shoulder_x / base._REF_SHOULDER_X
|
||||
|
||||
def along(b, frac):
|
||||
return b.head_local.x + frac * (b.tail_local.x - b.head_local.x)
|
||||
|
||||
cut_l = along(la_l, WRIST_FRAC) # left arm +x: delete x > cut_l
|
||||
cut_r = along(la_r, WRIST_FRAC) # right arm -x: delete x < cut_r
|
||||
lowerarm_len = abs(la_l.tail_local.x - la_l.head_local.x)
|
||||
wrist_x_abs = (abs(cut_l) + abs(cut_r)) / 2.0
|
||||
|
||||
waist_z = spine01.head_local.z
|
||||
hem_z = waist_z - HEM_DROP_FRAC * (waist_z - crotch_z)
|
||||
|
||||
# Arm rib axis (y, z) — midpoint of the lowerarm bone, shared by both
|
||||
# sides (the rig mirrors in x only).
|
||||
az_y = (la_l.head_local.y + la_l.tail_local.y) / 2.0
|
||||
az_z = (la_l.head_local.z + la_l.tail_local.z) / 2.0
|
||||
|
||||
lm = {
|
||||
"style": s,
|
||||
"neck_y": neck.head_local.y,
|
||||
"neck_len": neck.tail_local.z - neck.head_local.z,
|
||||
"cut_l": cut_l,
|
||||
"cut_r": cut_r,
|
||||
"wrist_x_abs": wrist_x_abs,
|
||||
"cuff_x0": wrist_x_abs - CUFF_LEN_FRAC * lowerarm_len,
|
||||
"arm_axis_yz": (az_y, az_z),
|
||||
"waist_z": waist_z,
|
||||
"crotch_z": crotch_z,
|
||||
"hem_z": hem_z,
|
||||
"collar_reach": COLLAR_REACH_FRAC * thr["collar_x_abs"],
|
||||
"rib_period": RIB_PERIOD_M * s,
|
||||
"line_half": LINE_HALF_M * s,
|
||||
}
|
||||
lm["hem_top"] = hem_z + HEM_BAND_FRAC * (thr["collar_z_min"] - hem_z)
|
||||
log(f"landmarks: style {s:.3f} wrist cuts ({cut_l:.3f},{cut_r:.3f}) "
|
||||
f"cuff_x0 {lm['cuff_x0']:.3f} hem {hem_z:.3f} (waist {waist_z:.3f}, "
|
||||
f"crotch {crotch_z:.3f}) hem_top {lm['hem_top']:.3f} "
|
||||
f"collar reach {lm['collar_reach']:.3f}")
|
||||
return lm
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: wrist + hem cuts, rim flattening, crew-collar circularization
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def wrist_cut(shell, lm):
|
||||
"""Trim the sleeve tubes at the wrist plane (hoodie/jacket pattern)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts
|
||||
if v.co.x > lm["cut_l"] or v.co.x < lm["cut_r"]]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"wrist cut removed {len(doomed)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def hem_cut(shell, lm):
|
||||
"""Trim the hips continuation below the hem plane (real hip geometry, so
|
||||
the surviving cloth conforms to the body — no invented coverage)."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
doomed = [v for v in bm.verts if v.co.z < lm["hem_z"]]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"hem cut at z={lm['hem_z']:.3f}: removed {len(doomed)} verts")
|
||||
|
||||
|
||||
def crew_cut(shell, thr, lm):
|
||||
"""Cut the neck tube down to crew height (probe-driven, per body).
|
||||
|
||||
The seg_torso_upper neck boundary is a jagged tooth ring: valley at
|
||||
~neck_z + 0.23*neck_len (crew height), teeth up to ~72% of the neck.
|
||||
Measure the ring (valley z, median + max horizontal distance to the neck
|
||||
axis), then delete every vert ABOVE the valley within hd_max * CUT_R_MUL
|
||||
of the axis — the tube and its teeth, and provably not the neck-trap
|
||||
flare shell (probe: flare hd sits well outside hd_max on all bodies).
|
||||
Stores crew_z / crew_r in lm for the flatten pass.
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
|
||||
mid_l = 0.5 * (thr["sleeve_x_abs"] + lm["cut_l"])
|
||||
mid_r = -0.5 * (thr["sleeve_x_abs"] + abs(lm["cut_r"]))
|
||||
z_split = 0.5 * (lm["hem_z"] + thr["collar_z_min"])
|
||||
ring = []
|
||||
for i in boundary:
|
||||
v = bm.verts[i]
|
||||
if v.co.x > mid_l or v.co.x < mid_r or v.co.z <= z_split:
|
||||
continue
|
||||
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
|
||||
if hd <= lm["collar_reach"] * 1.5:
|
||||
ring.append((hd, v.co.z))
|
||||
if not ring:
|
||||
bm.free()
|
||||
raise RuntimeError("no neck boundary ring found for crew cut")
|
||||
lm["crew_z"] = min(z for _, z in ring)
|
||||
lm["crew_r"] = float(np.median([hd for hd, _ in ring]))
|
||||
hd_max = max(hd for hd, _ in ring)
|
||||
r_cut = hd_max * CUT_R_MUL
|
||||
|
||||
doomed = [v for v in bm.verts
|
||||
if v.co.z > lm["crew_z"]
|
||||
and math.hypot(v.co.x, v.co.y - lm["neck_y"]) <= r_cut]
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
lm["collar_band_z0"] = lm["crew_z"] - COLLAR_BAND_FRAC * lm["neck_len"]
|
||||
log(f"crew cut: ring valley z={lm['crew_z']:.3f} r_med={lm['crew_r']:.3f} "
|
||||
f"hd_max={hd_max:.3f} -> removed {len(doomed)} tube verts "
|
||||
f"(r_cut {r_cut:.3f}); collar band z0={lm['collar_band_z0']:.3f}")
|
||||
|
||||
|
||||
def flatten_rims_and_collar(shell, thr, lm):
|
||||
"""Clean every open rim (adapted from the denim flatten_open_rims
|
||||
practice; only boundary verts move, weights/UVs ride along).
|
||||
|
||||
wrist rings -> the wrist cut planes (x per side)
|
||||
hem ring -> the hem plane (z)
|
||||
neck ring -> the crew plane (crew_z from crew_cut): verts within
|
||||
crew_r * CREW_RIM_R_MUL of the neck axis circularize to
|
||||
the ring median radius (clean crew rim); flare verts
|
||||
beyond only drop onto the plane — no radial pinch, which
|
||||
would fold cloth on wide-trap bodies.
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
|
||||
mid_l = 0.5 * (thr["sleeve_x_abs"] + lm["cut_l"])
|
||||
mid_r = -0.5 * (thr["sleeve_x_abs"] + abs(lm["cut_r"]))
|
||||
z_split = 0.5 * (lm["hem_z"] + thr["collar_z_min"])
|
||||
|
||||
wrist_n = hem_n = rim_n = flare_n = 0
|
||||
stray = 0
|
||||
for i in boundary:
|
||||
v = bm.verts[i]
|
||||
if v.co.x > mid_l:
|
||||
v.co.x = lm["cut_l"]
|
||||
wrist_n += 1
|
||||
elif v.co.x < mid_r:
|
||||
v.co.x = lm["cut_r"]
|
||||
wrist_n += 1
|
||||
elif v.co.z <= z_split:
|
||||
v.co.z = lm["hem_z"]
|
||||
hem_n += 1
|
||||
else:
|
||||
hd = math.hypot(v.co.x, v.co.y - lm["neck_y"])
|
||||
if hd <= lm["crew_r"] * CREW_RIM_R_MUL:
|
||||
if hd > 1e-6:
|
||||
f = lm["crew_r"] / hd
|
||||
v.co.x *= f
|
||||
v.co.y = lm["neck_y"] + (v.co.y - lm["neck_y"]) * f
|
||||
v.co.z = lm["crew_z"]
|
||||
rim_n += 1
|
||||
elif hd <= lm["collar_reach"] * 1.5:
|
||||
v.co.z = lm["crew_z"]
|
||||
flare_n += 1
|
||||
else:
|
||||
stray += 1
|
||||
|
||||
if rim_n == 0:
|
||||
log("WARNING: no crew rim verts found — collar left ragged")
|
||||
log(f"flattened rims: wrist {wrist_n} verts -> cut planes, "
|
||||
f"hem {hem_n} -> z={lm['hem_z']:.3f}, crew rim {rim_n} -> circle "
|
||||
f"r={lm['crew_r']:.3f} z={lm['crew_z']:.3f}, flare {flare_n} -> "
|
||||
f"plane, stray {stray}")
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
|
||||
|
||||
def offset_with_cuff_pinch(shell, offset, lm):
|
||||
"""Outward normal offset with the standoff tapering to CUFF_PINCH over
|
||||
the ribbed cuff band (knit cuffs hug the wrist)."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.normal_update()
|
||||
span = max(lm["wrist_x_abs"] - lm["cuff_x0"], 1e-6)
|
||||
pinched = 0
|
||||
for v in bm.verts:
|
||||
s = 1.0
|
||||
ax = abs(v.co.x)
|
||||
if ax > lm["cuff_x0"]:
|
||||
t = min((ax - lm["cuff_x0"]) / span, 1.0)
|
||||
s = 1.0 - (1.0 - CUFF_PINCH) * t
|
||||
pinched += 1
|
||||
v.co += v.normal * (offset * s)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"offset {offset*1000:.0f} mm outward "
|
||||
f"({pinched} cuff verts pinched to {CUFF_PINCH:.2f}x)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-pixel knit paint + region mask (buttondown bake machinery)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _rib_stripes(arc, period):
|
||||
"""Bool array: dark rib stripe (half of each rib pair)."""
|
||||
return np.mod(arc / period, 1.0) < 0.5
|
||||
|
||||
|
||||
def _classify_px(pos, thr, lm):
|
||||
"""Region mask rows: collar=R, body=G, cuffs+hem=B (spec)."""
|
||||
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
||||
ax = np.abs(x)
|
||||
hd = np.hypot(x, y - lm["neck_y"])
|
||||
collar = (z >= lm["collar_band_z0"]) & (hd <= lm["collar_reach"])
|
||||
band_b = (~collar) & ((ax >= lm["cuff_x0"]) | (z <= lm["hem_top"]))
|
||||
rgba = np.zeros((len(x), 4), dtype=np.float32)
|
||||
rgba[:, 1] = 1.0 # default: body -> G
|
||||
rgba[collar] = (1.0, 0.0, 0.0, 0.0) # crew collar -> R
|
||||
rgba[band_b] = (0.0, 0.0, 1.0, 0.0) # cuffs + hem -> B
|
||||
return rgba
|
||||
|
||||
|
||||
def _paint_px(pos, rows, thr, lm):
|
||||
"""Knit luminance detail: ribbed collar/cuff/hem + border stitch lines."""
|
||||
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
||||
ax = np.abs(x)
|
||||
hd = np.hypot(x, y - lm["neck_y"])
|
||||
period = lm["rib_period"]
|
||||
line = lm["line_half"]
|
||||
|
||||
collar = (z >= lm["collar_band_z0"]) & (hd <= lm["collar_reach"])
|
||||
cuff = (~collar) & (ax >= lm["cuff_x0"])
|
||||
hem = (~collar) & (~cuff) & (z <= lm["hem_top"])
|
||||
|
||||
mul = np.ones(len(x), dtype=np.float32)
|
||||
mul[collar | cuff | hem] = BAND_MUL
|
||||
|
||||
# Collar ribs: azimuth around the neck axis. atan2's +-pi discontinuity
|
||||
# sits where the 2nd arg is negative — (y-cy)*FRONT_Y_SIGN < 0 is the
|
||||
# BACK on this rig, so the rib wrap seam hides at centre-back.
|
||||
arc = np.arctan2(x, (y - lm["neck_y"]) * base.FRONT_Y_SIGN) * hd
|
||||
sel = collar & _rib_stripes(arc, period)
|
||||
mul[sel] *= RIB_DARK
|
||||
|
||||
# Cuff ribs: azimuth around the arm axis (wrap seam under the arm).
|
||||
ay, az_ = lm["arm_axis_yz"]
|
||||
r_arm = np.hypot(y - ay, z - az_)
|
||||
arc = np.arctan2(y - ay, z - az_) * r_arm
|
||||
sel = cuff & _rib_stripes(arc, period)
|
||||
mul[sel] *= RIB_DARK
|
||||
|
||||
# Hem ribs: azimuth around the torso axis (wrap seam at centre-back).
|
||||
r_t = np.hypot(x, y - lm["hem_cy"])
|
||||
arc = np.arctan2(x, (y - lm["hem_cy"]) * base.FRONT_Y_SIGN) * r_t
|
||||
sel = hem & _rib_stripes(arc, period)
|
||||
mul[sel] *= RIB_DARK
|
||||
|
||||
# Border stitch lines at each band's inner edge.
|
||||
mul[(np.abs(z - lm["collar_band_z0"]) <= line)
|
||||
& (hd <= lm["collar_reach"] * 1.1)] = LINE_MUL
|
||||
mul[np.abs(ax - lm["cuff_x0"]) <= line] = LINE_MUL
|
||||
mul[(np.abs(z - lm["hem_top"]) <= line) & (~cuff)] = LINE_MUL
|
||||
|
||||
out = rows * mul[:, None]
|
||||
np.clip(out, 0.0, 1.0, out)
|
||||
return out
|
||||
|
||||
|
||||
def bake_maps(shell, thr, lm, mask_path):
|
||||
"""One pass over UV0 triangles: bake <body>_mask.png + painted albedo."""
|
||||
tris = bd._gather_tris(shell)
|
||||
|
||||
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
|
||||
mbuf[:, :, 1] = 1.0 # body-green background (bilinear-bleed safe)
|
||||
|
||||
rng = np.random.default_rng(ALBEDO_SEED)
|
||||
fabric = np.array(FABRIC_RGB, dtype=np.float32)
|
||||
noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
|
||||
- 0.5) * 2.0 * FABRIC_NOISE
|
||||
abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
||||
|
||||
# Hem torso-axis centre from the final mesh (paint anchor).
|
||||
hem_ys = [v.co.y for v in shell.data.vertices if v.co.z <= lm["hem_top"]]
|
||||
lm["hem_cy"] = float(np.mean(hem_ys)) if hem_ys else 0.0
|
||||
|
||||
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
|
||||
cover = bd._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
|
||||
if cover is not None:
|
||||
pos = bd._interp_pos(cover, co_a, co_b, co_c)
|
||||
mbuf[cover[0], cover[1], :] = _classify_px(pos, thr, lm)
|
||||
cover = bd._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
|
||||
if cover is not None:
|
||||
pos = bd._interp_pos(cover, co_a, co_b, co_c)
|
||||
abuf[cover[0], cover[1], :] = _paint_px(
|
||||
pos, abuf[cover[0], cover[1], :], thr, lm)
|
||||
|
||||
tot = MASK_SIZE * MASK_SIZE
|
||||
log("mask texels: collar={:.1f}% body={:.1f}% cuffs+hem={:.1f}%".format(
|
||||
100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
|
||||
100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
|
||||
100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
|
||||
|
||||
mask_img = bpy.data.images.new(f"{GARMENT_ID}_mask", MASK_SIZE, MASK_SIZE,
|
||||
alpha=True)
|
||||
mask_img.pixels.foreach_set(mbuf.reshape(-1))
|
||||
mask_img.update()
|
||||
mask_img.filepath_raw = mask_path
|
||||
mask_img.file_format = 'PNG'
|
||||
mask_img.save()
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
|
||||
argba = np.concatenate(
|
||||
[abuf, np.ones((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)],
|
||||
axis=2)
|
||||
albedo_img = bpy.data.images.new(f"{GARMENT_ID}_albedo", ALBEDO_SIZE,
|
||||
ALBEDO_SIZE, alpha=False)
|
||||
albedo_img.pixels.foreach_set(argba.reshape(-1))
|
||||
albedo_img.update()
|
||||
return albedo_img
|
||||
|
||||
|
||||
def save_albedo(albedo_img, out_dir, body):
|
||||
"""Sidecar + embed naming (hoodie convention): the glTF exporter names the
|
||||
embedded image after the file basename and Godot extracts it as
|
||||
<glb>_<imagename>.png, so pointing the image at base_albedo.png makes the
|
||||
extraction land exactly on <body>_base_albedo.png."""
|
||||
sidecar = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
albedo_img.filepath_raw = sidecar
|
||||
albedo_img.file_format = 'PNG'
|
||||
albedo_img.save()
|
||||
log(f"painted albedo -> {sidecar}")
|
||||
albedo_img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
|
||||
albedo_img.save()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_sweater(body_dir, out_dir, body, offset):
|
||||
crotch_z, _hips_top = denim.probe_hips_bounds(body_dir)
|
||||
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
thr = base.derive_thresholds(armature)
|
||||
lm = derive_landmarks(armature, thr, crotch_z)
|
||||
|
||||
denim.weld_boundaries(shell)
|
||||
wrist_cut(shell, lm)
|
||||
hem_cut(shell, lm)
|
||||
crew_cut(shell, thr, lm)
|
||||
flatten_rims_and_collar(shell, thr, lm)
|
||||
offset_with_cuff_pinch(shell, offset, lm)
|
||||
base.solidify(shell, THICKNESS_M)
|
||||
|
||||
base.author_logo_uv(shell, thr) # UV2 before bakes (mask/albedo use UV0)
|
||||
albedo_img = bake_maps(shell, thr, lm,
|
||||
os.path.join(out_dir, f"{body}_mask.png"))
|
||||
save_albedo(albedo_img, out_dir, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global OFFSET_M, HEM_DROP_FRAC, RIB_PERIOD_M, FABRIC_RGB
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--hem-drop F] [--rib-period M] "
|
||||
"[--base-rgb r,g,b]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--hem-drop" in argv:
|
||||
HEM_DROP_FRAC = float(argv[argv.index("--hem-drop") + 1])
|
||||
if "--rib-period" in argv:
|
||||
RIB_PERIOD_M = float(argv[argv.index("--rib-period") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
FABRIC_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"per-body sweater: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
|
||||
f"hem-drop {HEM_DROP_FRAC}")
|
||||
|
||||
avg_albedo_stash = os.path.join(out_dir, "_tmp_avg_base_albedo.png")
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_sweater(body_dir, out_dir, body, offset)
|
||||
if body == base.REFERENCE_BODY:
|
||||
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
||||
avg_albedo_stash)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc: # noqa: BLE001 — per-body isolation
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Runtime fallbacks mirror the reference body (average_m).
|
||||
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log("copied reference_mask.png fallback")
|
||||
if os.path.isfile(avg_albedo_stash):
|
||||
shutil.move(avg_albedo_stash, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"base_albedo.png = {base.REFERENCE_BODY}'s painted albedo")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,472 @@
|
||||
"""
|
||||
blender_author_swim_trunks.py (T-1089 wave 2, swim set — swim_trunks)
|
||||
|
||||
Authors bright SWIM TRUNKS as per-body offset shells: hips + upper legs with
|
||||
the hem clearly ABOVE the knee (shorter than shorts_modern's 0.78 thigh
|
||||
fraction), a painted centre-front drawstring (knot + two hanging cords) and a
|
||||
lateral SIDE PANEL colour block down each leg.
|
||||
|
||||
Regions (RGBA mask, toon_garment.gdshader):
|
||||
waistband -> R (tint_0), body -> G (tint_1), side panel -> B (tint_2)
|
||||
|
||||
Companion to blender_author_offset_shell.py (imported as a library — scene
|
||||
build/join, offset, solidify, GLB export). The bottoms-specific practices are
|
||||
REUSED from the proven wave-1 companions, not rediscovered:
|
||||
|
||||
* denim (blender_author_denim_pants.py): boundary WELD of coincident
|
||||
segment-seam rings before offsetting (un-welded rings offset apart along
|
||||
diverging normals -> cracks), open-rim FLATTENING onto clean planes (the
|
||||
segment splitter leaves 4.5-7.6 cm jagged teeth at the waist/hem rims),
|
||||
the feathered --waist-flare deep-crouch mitigation, the post-solidify
|
||||
waist residue clamp, and the parked logo TEXCOORD_1 layer (trunks are not
|
||||
logo-capable, but toon_garment.gdshader samples UV2 unconditionally).
|
||||
* legs (blender_author_offset_shell_legs.py): the bone-plane hem cut.
|
||||
|
||||
Like the denim script, albedo and region mask are painted TEXEL-level from
|
||||
ONE analytic feature-field evaluation per texel (UV0 triangles rasterized
|
||||
with barycentric-interpolated 3D positions), so the painted drawstring/panel
|
||||
and the recolor regions always agree:
|
||||
- albedo: bright saturated default (coral body, teal side panel), painted
|
||||
drawstring knot + hanging cords (centre front), waistband border
|
||||
+ hem border stitches, side-panel edge piping — flat and
|
||||
toon-friendly, identity carried by the texture (style pin:
|
||||
modern only).
|
||||
- mask: waistband R, body G, side panel B.
|
||||
|
||||
All cut/mask/paint parameters derive PER BODY from that body's own bone
|
||||
landmarks (thigh_l/r) and measured mesh extents (waist rim valley, hem
|
||||
plane), scaled by the body's garment span and hip half-width — the same
|
||||
proportional-ratio philosophy as base.derive_thresholds. Per-body mode only
|
||||
(offset shells author per body, Q-060).
|
||||
|
||||
Usage (swim_trunks reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_swim_trunks.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/swim_trunks \
|
||||
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 0.60] \
|
||||
[--band-frac 0.14] [--panel-frac 0.42] [--waist-flare 0.007] \
|
||||
[--base-rgb 0.95,0.45,0.35] [--panel-rgb 0.13,0.68,0.64] [--plain]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the wave-1 modules (shared machinery — reused, not copied)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(mod_name, file_name):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
mod_name, os.path.join(_HERE, file_name))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
base = _load("offset_shell_base", "blender_author_offset_shell.py")
|
||||
denim = _load("denim_pants", "blender_author_denim_pants.py")
|
||||
legs = _load("offset_shell_legs", "blender_author_offset_shell_legs.py")
|
||||
|
||||
log = base.log
|
||||
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters (defaults = swim_trunks)
|
||||
# --------------------------------------------------------------------------
|
||||
COVERED_SEGMENTS = ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"]
|
||||
|
||||
HEM_FRAC = 0.60 # fraction of the thigh bone KEPT below its head.
|
||||
# 0.60 puts the hem ~17 cm above the knee on
|
||||
# average_m — clearly shorter than shorts_modern's
|
||||
# 0.78 (~9 cm above the knee), per the swim spec.
|
||||
TEX_SIZE = 1024 # albedo + mask resolution (painted details need >512)
|
||||
WAIST_FLARE_M = 0.007 # feathered radial stand-off at the waistband rim
|
||||
# (deep-crouch waist-fold mitigation — QA evidence
|
||||
# from the peasant + jeans sets, reused from denim)
|
||||
|
||||
# Vertical proportions — fractions of the garment span (waist_z - hem_z).
|
||||
BAND_FRAC = 0.14 # waistband height (R region) (~4.4 cm on average_m)
|
||||
SEAM_W_FRAC = 0.019 # painted stitch/piping line width (~6 mm on average_m)
|
||||
HEM_STITCH_FRAC = 0.028 # hem border stitch centre height above the hem
|
||||
CORD_LEN_FRAC = 0.20 # drawstring cord drop below the knot (~6.3 cm)
|
||||
|
||||
# Horizontal proportions — fractions of the hip half-width (|thigh head x|).
|
||||
PANEL_HW_FRAC = 0.42 # side-panel half ARC width (~3.8 cm -> 7.6 cm panel)
|
||||
|
||||
# Drawstring metrics in metres on average_m, scaled by the body's hip ratio.
|
||||
KNOT_R_M = 0.009 # knot blob radius
|
||||
CORD_W_M = 0.0055 # cord line width
|
||||
CORD_X0_M = 0.011 # cord |x| offset just below the knot
|
||||
CORD_SLANT = 0.28 # outward drift of the cords per metre of drop
|
||||
|
||||
# Swim style (sRGB floats; saved as-is — matches the proven base pipeline).
|
||||
# Bright saturated default per spec: coral body / teal side panel. Luma of
|
||||
# both stays near the toon_garment.gdshader recolor sweet spot (~0.5-0.6).
|
||||
CORAL_RGB = (0.95, 0.45, 0.35) # body + waistband base (luma ~0.59)
|
||||
TEAL_RGB = (0.13, 0.68, 0.64) # side panel (luma ~0.51)
|
||||
TRIM_RGB = (0.97, 0.95, 0.90) # drawstring + stitches/piping (cream)
|
||||
BAND_SHADE = 0.93 # waistband albedo darkening (reads untinted)
|
||||
ALBEDO_NOISE = 0.020 # +/- woven jitter
|
||||
PLAIN = False # --plain: skip drawstring/stitch/piping paint
|
||||
NOISE_SEED = 3089
|
||||
|
||||
# Reference proportions (average_m) the fractions were calibrated against:
|
||||
# waist rim valley 1.0312, hem(0.60) 0.7139 -> span 0.3173; |thigh head x|.
|
||||
_REF_HIP_X = 0.0906
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TrunkLandmarks:
|
||||
"""Cut/mask/paint parameters derived from one body's bones + mesh."""
|
||||
|
||||
def __init__(self, armature):
|
||||
bones = armature.data.bones
|
||||
thigh_l = bones.get("thigh_l")
|
||||
thigh_r = bones.get("thigh_r")
|
||||
if thigh_l is None or thigh_r is None:
|
||||
raise RuntimeError("thigh_l/thigh_r missing — not the 65-bone rig?")
|
||||
self.hip_x = (abs(thigh_l.head_local.x) + abs(thigh_r.head_local.x)) / 2.0
|
||||
# Hem plane: fraction of the thigh bone kept below its head (averaged
|
||||
# over both sides — they are symmetric on every shipped body).
|
||||
self.hem_z = (
|
||||
thigh_l.head_local.z + HEM_FRAC * (thigh_l.tail_local.z - thigh_l.head_local.z)
|
||||
+ thigh_r.head_local.z + HEM_FRAC * (thigh_r.tail_local.z - thigh_r.head_local.z)
|
||||
) / 2.0
|
||||
# Leg axis control points (z-increasing: knee -> hip) for the azimuth
|
||||
# side-panel placement. x values are the +x (left) leg; the right leg
|
||||
# mirrors via sign.
|
||||
self.leg_z_pts = np.array([thigh_l.tail_local.z, thigh_l.head_local.z])
|
||||
self.leg_x_pts = np.array(
|
||||
[abs(thigh_l.tail_local.x), abs(thigh_l.head_local.x)])
|
||||
self.leg_y_pts = np.array([thigh_l.tail_local.y, thigh_l.head_local.y])
|
||||
log(f"landmarks: hem plane z={self.hem_z:.3f} "
|
||||
f"(thigh head {thigh_l.head_local.z:.3f} -> knee "
|
||||
f"{thigh_l.tail_local.z:.3f}, keep {HEM_FRAC:.2f}) "
|
||||
f"hip_x={self.hip_x:.3f}")
|
||||
|
||||
def finalize(self, waist_plane, hem_plane):
|
||||
"""Derive paint metrics from the CLEAN (flattened) rims."""
|
||||
self.waist_z = waist_plane
|
||||
self.hem_plane = hem_plane
|
||||
self.span = waist_plane - hem_plane
|
||||
self.sh = self.hip_x / _REF_HIP_X
|
||||
self.band_h = BAND_FRAC * self.span
|
||||
self.band_z = waist_plane - self.band_h
|
||||
self.seam_w = SEAM_W_FRAC * self.span
|
||||
self.panel_hw = PANEL_HW_FRAC * self.hip_x
|
||||
self.hem_stitch_z = hem_plane + HEM_STITCH_FRAC * self.span
|
||||
self.knot_z = self.band_z + 0.55 * self.band_h
|
||||
self.knot_r = KNOT_R_M * self.sh
|
||||
self.cord_len = CORD_LEN_FRAC * self.span
|
||||
self.cord_w = CORD_W_M * self.sh
|
||||
self.cord_x0 = CORD_X0_M * self.sh
|
||||
log(f"finalized: waist={self.waist_z:.3f} hem={self.hem_plane:.3f} "
|
||||
f"span={self.span:.3f} band_z={self.band_z:.3f} "
|
||||
f"panel_hw={self.panel_hw * 100:.1f}cm "
|
||||
f"seam_w={self.seam_w * 1000:.1f}mm knot_z={self.knot_z:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Swim feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _trunk_field(px, py, pz, lm):
|
||||
"""Evaluate swim-trunk features at texel 3D positions (numpy arrays).
|
||||
|
||||
Returns bool arrays (in_band, panel, trim):
|
||||
`in_band`/`panel` feed the mask R/B channels; `trim` is every painted
|
||||
cream detail (drawstring, border stitches, panel edge piping).
|
||||
"""
|
||||
w2 = lm.seam_w * 0.5
|
||||
front = py * FRONT_Y_SIGN > 0.004
|
||||
in_band = pz >= lm.band_z
|
||||
mid = ~in_band
|
||||
|
||||
# Per-z leg axis, mirrored by x sign (clamps to the hip values above the
|
||||
# thigh head, so the panel runs straight up through the hip to the band).
|
||||
side = np.where(px >= 0.0, 1.0, -1.0)
|
||||
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
|
||||
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
r = np.hypot(dx, dy) + 1e-9
|
||||
|
||||
# Side panel: azimuth toward the outer (+/-x) direction; constant metric
|
||||
# width via arc distance (same construction as the denim outseam).
|
||||
arc_out = np.arccos(np.clip(dx * side / r, -1.0, 1.0)) * r
|
||||
panel = mid & (arc_out < lm.panel_hw)
|
||||
|
||||
# Painted trim: panel edge piping, waistband border, hem border.
|
||||
piping = mid & (np.abs(arc_out - lm.panel_hw) < w2 * 0.8)
|
||||
wstitch = np.abs(pz - lm.band_z) < w2 * 0.7
|
||||
hstitch = np.abs(pz - lm.hem_stitch_z) < w2 * 0.7
|
||||
|
||||
# Drawstring: knot blob + two cords hanging from it, centre front,
|
||||
# drifting slightly outward as they drop.
|
||||
knot = front & (np.hypot(px, pz - lm.knot_z) < lm.knot_r)
|
||||
drop = np.clip(lm.knot_z - pz, 0.0, None)
|
||||
cord_sep = lm.cord_x0 + CORD_SLANT * drop
|
||||
cords = front & (pz < lm.knot_z) & (pz > lm.knot_z - lm.cord_len) \
|
||||
& (np.abs(np.abs(px) - cord_sep) < lm.cord_w * 0.5)
|
||||
|
||||
trim = piping | wstitch | hstitch | knot | cords
|
||||
return in_band, panel, trim
|
||||
|
||||
|
||||
def _paint_texels(px, py, pz, noise, lm):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
||||
n = px.shape[0]
|
||||
in_band, panel, trim = _trunk_field(px, py, pz, lm)
|
||||
|
||||
# --- albedo: coral base, shaded band, teal panel, cream trim ------------
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = CORAL_RGB[c] + noise
|
||||
alb[:, 3] = 1.0
|
||||
alb[in_band, :3] *= BAND_SHADE
|
||||
for c in range(3):
|
||||
alb[panel, c] = TEAL_RGB[c] + noise[panel]
|
||||
if not PLAIN:
|
||||
alb[trim, 0] = TRIM_RGB[0]
|
||||
alb[trim, 1] = TRIM_RGB[1]
|
||||
alb[trim, 2] = TRIM_RGB[2]
|
||||
np.clip(alb, 0.0, 1.0, out=alb)
|
||||
|
||||
# --- region mask: waistband R / body G / side panel B --------------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
mask[in_band, 0] = 1.0
|
||||
mask[panel, 2] = 1.0
|
||||
mask[~(in_band | panel), 1] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the swim field, write albedo + mask together (same rasterizer
|
||||
contract as the denim companion, driving this garment's field)."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = CORAL_RGB[c] + noise_buf
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = body green (bilinear-bleed safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
lm, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
|
||||
total = float(W * H)
|
||||
r_pct = 100.0 * float((mask_buf[:, :, 0] > 0.5).sum()) / total
|
||||
b_pct = 100.0 * float((mask_buf[:, :, 2] > 0.5).sum()) / total
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H}); "
|
||||
f"mask texels: R(waistband)={r_pct:.1f}% B(panel)={b_pct:.1f}% "
|
||||
f"(rest G/background)")
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
albedo_img = _save(alb_buf, f"swim_albedo_{body}", albedo_path)
|
||||
_save(mask_buf, f"swim_mask_{body}", mask_path)
|
||||
log(f"saved albedo -> {albedo_path}")
|
||||
log(f"saved mask -> {mask_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_trunks_shell(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
denim.weld_boundaries(shell)
|
||||
|
||||
lm = TrunkLandmarks(armature)
|
||||
legs.hem_cut(shell, lm.hem_z)
|
||||
waist_plane, hem_plane = denim.flatten_open_rims(shell, lm.hem_z)
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
denim.waist_flare(shell, waist_plane,
|
||||
BAND_FRAC * (waist_plane - hem_plane), WAIST_FLARE_M)
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
denim.clamp_waist_residue(shell, waist_plane)
|
||||
|
||||
# Paint metrics reference the CLEAN rims (band under the flattened waist
|
||||
# edge, hem stitch above the flattened hem).
|
||||
lm.finalize(waist_plane, hem_plane)
|
||||
denim.author_parked_uv2(shell)
|
||||
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
global HEM_FRAC, BAND_FRAC, PANEL_HW_FRAC, WAIST_FLARE_M
|
||||
global CORAL_RGB, TEAL_RGB, PLAIN
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--hem-frac F] [--band-frac F] [--panel-frac F] "
|
||||
"[--waist-flare M] [--base-rgb r,g,b] [--panel-rgb r,g,b] "
|
||||
"[--plain]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = base.PER_BODY_OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--hem-frac" in argv:
|
||||
HEM_FRAC = float(argv[argv.index("--hem-frac") + 1])
|
||||
if "--band-frac" in argv:
|
||||
BAND_FRAC = float(argv[argv.index("--band-frac") + 1])
|
||||
if "--panel-frac" in argv:
|
||||
PANEL_HW_FRAC = float(argv[argv.index("--panel-frac") + 1])
|
||||
if "--waist-flare" in argv:
|
||||
WAIST_FLARE_M = float(argv[argv.index("--waist-flare") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
CORAL_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
if "--panel-rgb" in argv:
|
||||
TEAL_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--panel-rgb") + 1].split(","))
|
||||
if "--plain" in argv:
|
||||
PLAIN = True
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"swim-trunks per-body mode: {len(bodies)} bodies, offset "
|
||||
f"{offset * 1000:.0f} mm, hem-frac {HEM_FRAC}, band-frac {BAND_FRAC}, "
|
||||
f"panel-frac {PANEL_HW_FRAC}, plain={PLAIN}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_trunks_shell(body_dir, out_dir, body, offset)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,649 @@
|
||||
"""
|
||||
blender_author_swimsuit.py (T-1089 wave 2, swimsuit_onepiece — swim family)
|
||||
|
||||
Authors a one-piece SWIMSUIT as per-body offset shells, reusing
|
||||
blender_author_offset_shell.py as a library (scene build, join, offset,
|
||||
solidify, GLB export) and blender_author_offset_coverall.py's per-texel
|
||||
rasterizer (_tri_texels) for crisp mask/albedo boundaries. Coverage: torso +
|
||||
torso_upper + hips — NO arms, NO legs. The torso_upper is cut down to narrow
|
||||
SHOULDER STRAPS (front scoop + back scoop + open armholes); the bottom gets
|
||||
HIGH-CUT leg openings that rise from the crotch gusset to the hip line at the
|
||||
sides. What this companion adds, as reusable parameters:
|
||||
|
||||
* seg_leg_upper_l/r are INCLUDED in the covered set purely as cut stock:
|
||||
the seg_hips lower boundary is splitter teeth (probe: 9-13 cm jag,
|
||||
z 0.884..1.015 on average_m — worse than the waist teeth denim flattens).
|
||||
Welding the legs in makes that seam interior, so the high-cut leg cut
|
||||
slices through CLEAN thigh geometry and no natural teeth survive; the
|
||||
tubes below the cut are deleted whole.
|
||||
* measurement-guarded straps: the strap corridor is proportional
|
||||
(STRAP_X0/X1_FRAC of shoulder |x|) but clamped per body against the
|
||||
MEASURED neck-seam ring (max |x| + guard) and armscye ring (min |x| −
|
||||
guard), because the armscye reaches |x| = 0.79..0.92 x shoulder depending
|
||||
on body fork (probe) and a fixed fraction would slice into the seam.
|
||||
* measurement-derived armhole plane: z_arm = (armscye ring min z) − drop,
|
||||
so the whole jagged arm-seam ring is guaranteed deleted on every body.
|
||||
* analytic high-cut leg surface z_leg(x, y): crotch gusset (below-crotch at
|
||||
|x| < gusset half-width, so the gusset never opens), rising outward to
|
||||
the thigh-head line, with a front/back blend that keeps the seat covered
|
||||
(RISE_BACK < RISE_FRONT). The SAME function drives the cut, the rim
|
||||
flattening and the trim band in the mask, so they always agree.
|
||||
* open-rim FLATTENING onto the analytic lines (denim practice, post-offset):
|
||||
scoop rims -> scoop plane, armhole rims -> z_arm, leg rims -> z_leg,
|
||||
strap side edges -> |x| snapped to the exact strap planes.
|
||||
|
||||
Regions (RGBA mask, toon_garment.gdshader; spec: straps+trim=R, body=G,
|
||||
side color-block=B):
|
||||
R = shoulder straps + edge trim along every opening (scoop/armhole/leg)
|
||||
G = main body fabric
|
||||
B = side color-block panels (normal-gated: |n.x| >= BLOCK_NX_MIN, below the
|
||||
armholes) — the saturated-color-block default lives in the tints.
|
||||
|
||||
Painted albedo: flat per-region luminance + woven noise + dark stitch lines on
|
||||
region boundaries (toon-friendly; identity carried by the texture). A parked
|
||||
logo_uv TEXCOORD_1 layer ships for channel consistency (not logo-capable).
|
||||
|
||||
Per-body mode only (offset shells author per body, Q-060):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_swimsuit.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/swimsuit_onepiece \
|
||||
[--bodies average_m,child,...] [--offset 0.012]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
||||
wardrobe), Q-060 (per-body offset shells).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# Make the sibling modules importable when Blender runs this file directly.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import blender_author_offset_shell as base # noqa: E402
|
||||
import blender_author_offset_coverall as cov # noqa: E402 (per-texel rasterizer)
|
||||
|
||||
log = base.log
|
||||
FRONT = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
COVERED_SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper", "seg_hips",
|
||||
# cut stock only — welded in so the hips|leg seam teeth become interior,
|
||||
# then everything below the high-cut line is deleted.
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
]
|
||||
|
||||
WELD_DIST = 5e-4 # boundary weld tolerance (0.5 mm, denim practice)
|
||||
MIN_LOOP_VERTS = 6 # ignore sliver boundary loops when measuring rings
|
||||
|
||||
# Straps — proportional corridor over the shoulder top, clamped per body
|
||||
# against the measured neck ring (outside it) and armscye ring (inside it).
|
||||
STRAP_X0_FRAC = 0.52 # inner strap edge, fraction of shoulder |x|
|
||||
STRAP_X1_FRAC = 0.74 # outer strap edge
|
||||
STRAP_MIN_W_FRAC = 0.10 # minimum acceptable strap width (of shoulder |x|)
|
||||
NECK_GUARD_M = 0.004 # keep this far outside the neck-seam ring
|
||||
ARM_GUARD_M = 0.004 # keep this far inside the armscye ring
|
||||
|
||||
# Necklines — fractions of the spine_01->neck_01 span above spine_01 head.
|
||||
FRONT_SCOOP_FRAC = 0.66 # front neckline (covers the seg_torso top band)
|
||||
BACK_SCOOP_FRAC = 0.54 # back scoop, slightly deeper
|
||||
ARMHOLE_DROP_M_REF = 0.015 # armhole plane below the measured armscye min z
|
||||
|
||||
# High-cut leg openings — all spans derive from (thigh head z − crotch z).
|
||||
GUSSET_HALF_FRAC = 0.50 # crotch gusset half-width, fraction of thigh |x|
|
||||
LEG_OUT_X_FRAC = 1.55 # |x| where the rise reaches its max (of thigh |x|)
|
||||
LEG_RISE_FRONT_FRAC = 1.00 # front/side rise: up to the thigh-head line
|
||||
LEG_RISE_BACK_FRAC = 0.58 # back rise: lower, keeps the seat covered
|
||||
LEG_RISE_POW = 1.35 # >1 = convex sweep (classic high-cut)
|
||||
LEG_YBLEND_FRAC = 0.35 # front/back blend half-width (of thigh |x|)
|
||||
GUSSET_DROP_M_REF = 0.004 # gusset cut sits below the crotch -> never opens
|
||||
|
||||
# Region mask / painted albedo.
|
||||
TRIM_W_M_REF = 0.016 # edge-trim band width along the openings
|
||||
BLOCK_NX_MIN = 0.74 # side panel: |normal.x| threshold (normal-gated)
|
||||
ALBEDO_LUMA = {"body": 0.62, "trim": 0.56, "block": 0.67}
|
||||
STITCH_LUMA = 0.30
|
||||
ALBEDO_NOISE = 0.02
|
||||
NOISE_SEED = 3089
|
||||
|
||||
LABELS = ["body", "trim", "block"]
|
||||
LABEL_ID = {name: i for i, name in enumerate(LABELS)}
|
||||
LABEL_RGBA_ARR = np.array(
|
||||
[
|
||||
(0.0, 1.0, 0.0, 0.0), # body -> G
|
||||
(1.0, 0.0, 0.0, 0.0), # trim -> R (straps + edge trim)
|
||||
(0.0, 0.0, 1.0, 0.0), # block -> B (side color-block)
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
LABEL_LUMA_ARR = np.array([ALBEDO_LUMA[n] for n in LABELS], dtype=np.float32)
|
||||
|
||||
_REF_SHOULDER_X = 0.1919 # average_m upperarm head |x| (same anchor as base)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body landmarks + measured rings
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class SuitLandmarks:
|
||||
"""Cut/mask parameters from one body's bones, crotch probe and rings."""
|
||||
|
||||
def __init__(self, armature, crotch_z):
|
||||
bones = armature.data.bones
|
||||
|
||||
def bone(name):
|
||||
b = bones.get(name)
|
||||
if b is None:
|
||||
raise RuntimeError(f"landmark bone {name} missing")
|
||||
return b
|
||||
|
||||
ua_l, ua_r = bone("upperarm_l"), bone("upperarm_r")
|
||||
self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
self.scale = self.shoulder_x / _REF_SHOULDER_X
|
||||
neck = bone("neck_01")
|
||||
spine01 = bone("spine_01")
|
||||
span = neck.head_local.z - spine01.head_local.z
|
||||
self.scoop_front = spine01.head_local.z + FRONT_SCOOP_FRAC * span
|
||||
self.scoop_back = spine01.head_local.z + BACK_SCOOP_FRAC * span
|
||||
|
||||
thigh = bone("thigh_l")
|
||||
self.thigh_x = abs(thigh.head_local.x)
|
||||
self.thigh_z = thigh.head_local.z
|
||||
self.crotch_z = crotch_z
|
||||
self.rise_span = self.thigh_z - self.crotch_z
|
||||
self.gx = GUSSET_HALF_FRAC * self.thigh_x
|
||||
self.out_x = LEG_OUT_X_FRAC * self.thigh_x
|
||||
self.yb = LEG_YBLEND_FRAC * self.thigh_x
|
||||
self.gusset_drop = GUSSET_DROP_M_REF * self.scale
|
||||
self.trim_w = TRIM_W_M_REF * self.scale
|
||||
|
||||
# Filled by apply_ring_measurements():
|
||||
self.sx0 = STRAP_X0_FRAC * self.shoulder_x
|
||||
self.sx1 = STRAP_X1_FRAC * self.shoulder_x
|
||||
self.z_arm = self.scoop_front # placeholder until rings are measured
|
||||
|
||||
def apply_ring_measurements(self, neck_max_ax, arm_min_ax, arm_min_z):
|
||||
self.sx0 = max(STRAP_X0_FRAC * self.shoulder_x,
|
||||
neck_max_ax + NECK_GUARD_M * self.scale)
|
||||
self.sx1 = min(STRAP_X1_FRAC * self.shoulder_x,
|
||||
arm_min_ax - ARM_GUARD_M * self.scale)
|
||||
min_w = STRAP_MIN_W_FRAC * self.shoulder_x
|
||||
if self.sx1 - self.sx0 < min_w:
|
||||
log(f"WARNING: strap corridor pinched "
|
||||
f"({(self.sx1 - self.sx0) * 1000:.1f} mm) — widening inward")
|
||||
self.sx0 = max(neck_max_ax + NECK_GUARD_M * self.scale,
|
||||
self.sx1 - min_w)
|
||||
self.z_arm = arm_min_z - ARMHOLE_DROP_M_REF * self.scale
|
||||
log(f"landmarks: straps |x|=[{self.sx0:.3f},{self.sx1:.3f}] "
|
||||
f"scoop_f={self.scoop_front:.3f} scoop_b={self.scoop_back:.3f} "
|
||||
f"z_arm={self.z_arm:.3f} crotch={self.crotch_z:.3f} "
|
||||
f"thigh_z={self.thigh_z:.3f} gusset<|x|<{self.gx:.3f} "
|
||||
f"trim={self.trim_w * 1000:.1f}mm")
|
||||
|
||||
# ---- analytic surfaces (numpy-vectorised; scalars work too) ----------
|
||||
|
||||
def z_leg(self, x, y):
|
||||
"""High-cut leg-opening surface: gusset floor below the crotch,
|
||||
rising outward to the thigh-head line; back rises less (seat)."""
|
||||
t = np.clip((np.abs(x) - self.gx) / max(self.out_x - self.gx, 1e-6),
|
||||
0.0, 1.0) ** LEG_RISE_POW
|
||||
f = np.clip((np.asarray(y) * FRONT + self.yb) / (2.0 * self.yb),
|
||||
0.0, 1.0)
|
||||
rise = self.rise_span * (
|
||||
LEG_RISE_BACK_FRAC + (LEG_RISE_FRONT_FRAC - LEG_RISE_BACK_FRAC) * f)
|
||||
return (self.crotch_z - self.gusset_drop) + t * rise
|
||||
|
||||
def scoop(self, y):
|
||||
"""Neckline level: front scoop on the front side, back scoop behind."""
|
||||
return np.where(np.asarray(y) * FRONT > 0.0,
|
||||
self.scoop_front, self.scoop_back)
|
||||
|
||||
|
||||
def probe_crotch(body_dir):
|
||||
"""Import seg_hips alone to measure the crotch (its lowest point) exactly
|
||||
(denim practice — the joined mesh's min z is the knee cut stock)."""
|
||||
base.clear_scene()
|
||||
objs = base.import_glb(os.path.join(body_dir, "seg_hips.glb"))
|
||||
zs = []
|
||||
for o in objs:
|
||||
if base.is_body_mesh(o):
|
||||
zs.extend(v.co.z for v in o.data.vertices)
|
||||
if not zs:
|
||||
raise RuntimeError("seg_hips.glb yielded no skinned mesh")
|
||||
return min(zs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: weld, ring measurement, cuts, rim flattening
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_boundaries(shell):
|
||||
"""Merge coincident segment-boundary verts so the offset can't open cracks
|
||||
(weights/UVs identical on coincident verts, so skinning is unaffected)."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"welded segment boundaries: {before} -> {len(me.vertices)} verts")
|
||||
|
||||
|
||||
def _boundary_loops(bm):
|
||||
"""Connected open-boundary loops as lists of vert indices."""
|
||||
adj = {}
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) != 1:
|
||||
continue
|
||||
a, b = e.verts[0].index, e.verts[1].index
|
||||
adj.setdefault(a, set()).add(b)
|
||||
adj.setdefault(b, set()).add(a)
|
||||
seen = set()
|
||||
loops = []
|
||||
for start in adj:
|
||||
if start in seen:
|
||||
continue
|
||||
stack, comp = [start], []
|
||||
while stack:
|
||||
v = stack.pop()
|
||||
if v in seen:
|
||||
continue
|
||||
seen.add(v)
|
||||
comp.append(v)
|
||||
stack.extend(adj[v] - seen)
|
||||
loops.append(comp)
|
||||
return loops
|
||||
|
||||
|
||||
def measure_rings(shell, lm):
|
||||
"""Locate the neck-seam and armscye boundary rings on the welded shell.
|
||||
|
||||
Expected loops: neck ring, 2 armscye rings, 2 knee rings (cut stock;
|
||||
median z below the crotch — ignored). Sliver loops (< MIN_LOOP_VERTS)
|
||||
are skipped. Returns (neck_max_ax, arm_min_ax, arm_min_z).
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
candidates = []
|
||||
for comp in _boundary_loops(bm):
|
||||
if len(comp) < MIN_LOOP_VERTS:
|
||||
continue
|
||||
zs = sorted(bm.verts[i].co.z for i in comp)
|
||||
med_z = zs[len(zs) // 2]
|
||||
if med_z < lm.crotch_z:
|
||||
continue # knee ring on the leg cut stock
|
||||
axs = [abs(bm.verts[i].co.x) for i in comp]
|
||||
candidates.append({
|
||||
"med_ax": sorted(axs)[len(axs) // 2],
|
||||
"max_ax": max(axs),
|
||||
"min_ax": min(axs),
|
||||
"min_z": min(bm.verts[i].co.z for i in comp),
|
||||
"n": len(comp),
|
||||
})
|
||||
bm.free()
|
||||
if len(candidates) < 3:
|
||||
raise RuntimeError(
|
||||
f"expected neck + 2 armscye rings, found {len(candidates)}")
|
||||
candidates.sort(key=lambda c: c["med_ax"])
|
||||
neck = candidates[0]
|
||||
arms = candidates[-2:]
|
||||
arm_min_ax = min(a["min_ax"] for a in arms)
|
||||
arm_min_z = min(a["min_z"] for a in arms)
|
||||
log(f"rings: neck max|x|={neck['max_ax']:.3f} ({neck['n']}v) "
|
||||
f"armscye min|x|={arm_min_ax:.3f} min z={arm_min_z:.3f}")
|
||||
return neck["max_ax"], arm_min_ax, arm_min_z
|
||||
|
||||
|
||||
def suit_cuts(shell, lm):
|
||||
"""Delete everything the swimsuit doesn't cover:
|
||||
- neckline scoops between the straps (|x| < sx0, z above scoop level)
|
||||
- armholes outside the straps (|x| > sx1, z above the armhole plane)
|
||||
- legs below the analytic high-cut surface z_leg(x, y)
|
||||
The strap corridor (sx0 <= |x| <= sx1) survives over the shoulder."""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
doomed = []
|
||||
for v in bm.verts:
|
||||
x, y, z = v.co.x, v.co.y, v.co.z
|
||||
ax = abs(x)
|
||||
if z < float(lm.z_leg(x, y)):
|
||||
doomed.append(v)
|
||||
elif ax < lm.sx0 and z > float(lm.scoop(y)):
|
||||
doomed.append(v)
|
||||
elif ax > lm.sx1 and z > lm.z_arm:
|
||||
doomed.append(v)
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"suit cuts removed {len(doomed)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def flatten_rims(shell, lm):
|
||||
"""Pull every open rim onto its analytic line (post-offset, denim
|
||||
practice): leg rims -> z_leg surface, scoop rims -> scoop plane, armhole
|
||||
rims -> z_arm plane, strap side edges -> |x| snapped to the strap planes.
|
||||
Only boundary verts move; weights/UVs ride along.
|
||||
|
||||
The leg/top split plane sits MID-GAP between the thigh line (the leg
|
||||
rims' analytic maximum) and the lowest top opening (armhole plane or
|
||||
back scoop) — a dead zone with no legitimate boundary verts. A tight
|
||||
margin (thigh_z + 2 cm) is NOT enough: the 12 mm outward offset runs
|
||||
before flattening and drifts the high-cut side-apex verts upward, and on
|
||||
thin_f two thigh-weighted apex verts crossed it, were classified as
|
||||
armhole rim and teleported ~36 cm up to z_arm — QA showed them as sliver
|
||||
spikes off the seat in deep Crouch_Fwd (worst vert-from-centroid 294 mm)."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
snap_eps = 0.006 * lm.scale
|
||||
leg_z_max = 0.5 * (lm.thigh_z + min(lm.z_arm, lm.scoop_back))
|
||||
counts = {"leg": 0, "scoop": 0, "armhole": 0, "strap": 0}
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
boundary.update(v.index for v in e.verts)
|
||||
for i in boundary:
|
||||
v = bm.verts[i]
|
||||
x, y, z = v.co.x, v.co.y, v.co.z
|
||||
ax = abs(x)
|
||||
if z < leg_z_max:
|
||||
v.co.z = float(lm.z_leg(x, y))
|
||||
counts["leg"] += 1
|
||||
elif ax < lm.sx0 - snap_eps:
|
||||
v.co.z = float(lm.scoop(y))
|
||||
counts["scoop"] += 1
|
||||
elif ax > lm.sx1 + snap_eps:
|
||||
v.co.z = lm.z_arm
|
||||
counts["armhole"] += 1
|
||||
else:
|
||||
edge = lm.sx0 if abs(ax - lm.sx0) <= abs(ax - lm.sx1) else lm.sx1
|
||||
v.co.x = edge if x >= 0.0 else -edge
|
||||
counts["strap"] += 1
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log("flattened rims: " + " ".join(f"{k}={v}" for k, v in counts.items()))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Region classification (per texel — interpolated position + normal)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def classify_texels(pos, nrm, lm):
|
||||
"""Classify N texels. pos/nrm are (N,3) body-local arrays. Returns (N,)
|
||||
uint8 label ids. Precedence: trim (straps + opening edges), block, body."""
|
||||
x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
|
||||
ax = np.abs(x)
|
||||
tw = lm.trim_w
|
||||
scoop = lm.scoop(y)
|
||||
zl = lm.z_leg(x, y)
|
||||
|
||||
strap = (ax >= lm.sx0 - 0.5 * tw) & (ax <= lm.sx1 + 0.5 * tw) \
|
||||
& (z >= scoop - tw)
|
||||
scoop_trim = (ax < lm.sx0) & (z >= scoop - tw)
|
||||
arm_trim = (ax > lm.sx1) & (z >= lm.z_arm - tw)
|
||||
leg_trim = z <= zl + tw
|
||||
trim = strap | scoop_trim | arm_trim | leg_trim
|
||||
|
||||
block = (np.abs(nrm[:, 0]) >= BLOCK_NX_MIN) & (z <= lm.z_arm - tw)
|
||||
|
||||
lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8)
|
||||
lab[block] = LABEL_ID["block"]
|
||||
lab[trim] = LABEL_ID["trim"]
|
||||
return lab
|
||||
|
||||
|
||||
def bake_mask_and_albedo(shell, lm, mask_path, albedo_name, seed):
|
||||
"""One pass over the faces (pre-solidify — exactly one face per texel):
|
||||
bake the RGBA region mask AND the painted albedo (flat per-region
|
||||
luminance + stitch lines on region boundaries + woven noise)."""
|
||||
W = H = base.MASK_SIZE
|
||||
mask = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
|
||||
albedo = np.zeros((H, W, 4), dtype=np.float32)
|
||||
albedo[:, :, 0:3] = ALBEDO_LUMA["body"]
|
||||
albedo[:, :, 3] = 1.0
|
||||
label_map = np.full((H, W), LABEL_ID["body"], dtype=np.uint8)
|
||||
covered = np.zeros((H, W), dtype=bool)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.normal_update()
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
if uv_layer is None:
|
||||
raise RuntimeError("no active UV layer for bake")
|
||||
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv for loop in loops]
|
||||
pos = [loop.vert.co for loop in loops]
|
||||
nrm = [loop.vert.normal for loop in loops]
|
||||
for i in range(1, len(loops) - 1):
|
||||
tri = (0, i, i + 1)
|
||||
ys, xs, w0, w1, w2 = cov._tri_texels(
|
||||
uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H)
|
||||
if ys.size == 0:
|
||||
continue
|
||||
p = np.empty((ys.size, 3), dtype=np.float32)
|
||||
n = np.empty((ys.size, 3), dtype=np.float32)
|
||||
for axis in range(3):
|
||||
p[:, axis] = (w0 * pos[tri[0]][axis] + w1 * pos[tri[1]][axis]
|
||||
+ w2 * pos[tri[2]][axis])
|
||||
n[:, axis] = (w0 * nrm[tri[0]][axis] + w1 * nrm[tri[1]][axis]
|
||||
+ w2 * nrm[tri[2]][axis])
|
||||
n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9)
|
||||
lab = classify_texels(p, n, lm)
|
||||
label_map[ys, xs] = lab
|
||||
covered[ys, xs] = True
|
||||
mask[ys, xs] = LABEL_RGBA_ARR[lab]
|
||||
albedo[ys, xs, 0:3] = LABEL_LUMA_ARR[lab][:, None]
|
||||
bm.free()
|
||||
total = max(int(covered.sum()), 1)
|
||||
tex_counts = np.bincount(label_map[covered], minlength=len(LABELS))
|
||||
log("region texels: " + " ".join(
|
||||
f"{LABELS[i]}={int(c)} ({100.0 * c / total:.1f}%)"
|
||||
for i, c in enumerate(tex_counts) if c > 0))
|
||||
|
||||
# Woven-feel noise over the fills, before stitch lines (lines stay crisp).
|
||||
rng = np.random.default_rng(seed)
|
||||
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE
|
||||
albedo[:, :, 0:3] = np.clip(albedo[:, :, 0:3] + noise, 0.0, 1.0)
|
||||
|
||||
# Stitch lines: label transitions where BOTH texels belong to rasterised
|
||||
# geometry (skipping UV-island borders against background).
|
||||
edge = np.zeros((H, W), dtype=bool)
|
||||
dh = (label_map[:, 1:] != label_map[:, :-1]) & covered[:, 1:] & covered[:, :-1]
|
||||
edge[:, 1:] |= dh
|
||||
edge[:, :-1] |= dh
|
||||
dv = (label_map[1:, :] != label_map[:-1, :]) & covered[1:, :] & covered[:-1, :]
|
||||
edge[1:, :] |= dv
|
||||
edge[:-1, :] |= dv
|
||||
albedo[edge, 0:3] = STITCH_LUMA
|
||||
log(f"stitch lines on {int(edge.sum())} boundary texels")
|
||||
|
||||
# Alpha floor 2/255: keeps Godot's fix_alpha_border import pass a no-op
|
||||
# (channel-packed region data, not transparency — coverall lesson).
|
||||
mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0)
|
||||
|
||||
img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True)
|
||||
img_mask.alpha_mode = 'CHANNEL_PACKED'
|
||||
img_mask.pixels.foreach_set(mask.reshape(-1))
|
||||
img_mask.update()
|
||||
img_mask.filepath_raw = mask_path
|
||||
img_mask.file_format = 'PNG'
|
||||
img_mask.save()
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
|
||||
img_albedo = bpy.data.images.new(f"albedo_{albedo_name}", W, H, alpha=False)
|
||||
img_albedo.pixels.foreach_set(albedo.reshape(-1))
|
||||
img_albedo.update()
|
||||
return img_albedo
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parked logo UV2 (not logo-capable; the shader still samples UV2)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_parked_uv2(shell):
|
||||
me = shell.data
|
||||
while len(me.uv_layers) > 1:
|
||||
me.uv_layers.remove(me.uv_layers[-1])
|
||||
me.uv_layers.new(name="logo_uv")
|
||||
me.uv_layers.active = me.uv_layers[0]
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
uvl = bm.loops.layers.uv.get("logo_uv")
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
loop[uvl].uv = (2.0, 2.0)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log("logo UV2 authored fully parked (not logo-capable)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def purge_strays(shell, armature):
|
||||
"""Drop authoring-scene leftovers before export (defensive hygiene).
|
||||
|
||||
Investigated for T-1089 wave 2: the 'Icosphere' seen when re-importing
|
||||
any garment GLB is NOT file content — the raw glTF JSON contains exactly
|
||||
one mesh (the shell). It is a bone-display widget the Blender IMPORTER
|
||||
fabricates (bone_heuristic), which also leaks one such object into the
|
||||
authoring scene per body-segment import session (base.clear_scene()'s
|
||||
bpy.ops select_all can miss it). Exports were never polluted
|
||||
(use_selection holds), but removing every object that is not the shell
|
||||
or its armature keeps each per-body authoring cycle hermetic. Bone
|
||||
custom_shape references are cleared for the same reason (display-only,
|
||||
no glTF effect)."""
|
||||
cleared = 0
|
||||
for pb in armature.pose.bones:
|
||||
if pb.custom_shape is not None:
|
||||
pb.custom_shape = None
|
||||
cleared += 1
|
||||
strays = [o for o in bpy.data.objects if o not in (shell, armature)]
|
||||
for o in strays:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
if cleared or strays:
|
||||
log(f"purged {len(strays)} stray objects, cleared {cleared} bone "
|
||||
f"custom shapes before export")
|
||||
|
||||
|
||||
def author_swimsuit(body_dir, out_dir, body, offset, seed):
|
||||
crotch_z = probe_crotch(body_dir)
|
||||
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
weld_boundaries(shell)
|
||||
|
||||
lm = SuitLandmarks(armature, crotch_z)
|
||||
lm.apply_ring_measurements(*measure_rings(shell, lm))
|
||||
|
||||
suit_cuts(shell, lm)
|
||||
base.offset_outward(shell, offset)
|
||||
flatten_rims(shell, lm)
|
||||
|
||||
# Bake + UV2 BEFORE solidify: the inner shell duplicates every face with
|
||||
# the same atlas UVs but flipped normals — pre-solidify there is exactly
|
||||
# one face per texel (coverall lesson; the block region is normal-gated).
|
||||
author_parked_uv2(shell)
|
||||
albedo_img = bake_mask_and_albedo(
|
||||
shell, lm, os.path.join(out_dir, f"{body}_mask.png"), body, seed)
|
||||
|
||||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
# Shared-name albedo before export so the GLB-embedded texture extracts
|
||||
# to the <body>_base_albedo.png convention (coverall lesson).
|
||||
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||||
purge_strays(shell, armature)
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
shutil.copy2(os.path.join(out_dir, "base_albedo.png"),
|
||||
os.path.join(out_dir, f"{body}_base_albedo.png"))
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] [--offset M]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = base.PER_BODY_OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"swimsuit per-body mode: {len(bodies)} bodies, "
|
||||
f"offset {offset * 1000:.0f} mm")
|
||||
|
||||
results = []
|
||||
for i, body in enumerate(bodies):
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_swimsuit(body_dir, out_dir, body, offset, seed=NOISE_SEED + i)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
# Runtime fallback + shared sidecars mirror the reference body.
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,565 @@
|
||||
"""
|
||||
blender_author_tank_top.py (T-1089 wave 2, tank_top — per-body offset shell)
|
||||
|
||||
Sleeveless tee authored per body via the offset-shell route. Imports
|
||||
blender_author_offset_shell.py as the shared library (scene build, join,
|
||||
offset, solidify, logo UV2, export), blender_author_denim_pants.py for the
|
||||
required boundary-WELD practice, and blender_author_buttondown.py for the
|
||||
per-pixel bake machinery (_gather_tris/_tri_cover/_interp_pos). What the tank
|
||||
adds beyond the tee base, as reusable parameters:
|
||||
|
||||
* coverage is torso + torso_upper ONLY — no arm segments. The armhole is a
|
||||
real CUT at the shoulder: verts outboard of the strap (|x| > strap_out)
|
||||
and above the underarm plane are deleted, leaving a shoulder strap between
|
||||
the neck scoop and the armhole.
|
||||
* scoop NECKLINE cut — front scoop lower than the back, blended smoothly
|
||||
across the +-y transition, both guaranteed below the jagged natural neck
|
||||
ring (the segment splitter's 4.5-7.6 cm teeth) so no natural boundary
|
||||
survives except the hem.
|
||||
* ring-swallowing thresholds — the natural neck ring and shoulder/arm rings
|
||||
are MEASURED per body (open-boundary probe after weld) and the strap /
|
||||
scoop / underarm cut planes are clamped so every jagged ring vert falls in
|
||||
the deleted zone. Proportional defaults derive from bone landmarks exactly
|
||||
like base.derive_thresholds.
|
||||
* open-rim treatment (denim practice, adapted): the hem ring is FLATTENED to
|
||||
a clean plane (the ring's deepest tooth, so the hem overlaps a pants
|
||||
waistband); the scoop + armhole cut edges are Laplacian-SMOOTHED along the
|
||||
boundary loops into fair curves (a plane can't represent a curved scoop).
|
||||
* region mask is distance-to-opening based: texels within TRIM_W of the
|
||||
neckline edge -> collar band (R), within TRIM_W of an armhole edge ->
|
||||
armhole trim (B), everything else -> body (G). The painted albedo shares
|
||||
the same distance fields (binding bands + stitch lines + hem stitch), so
|
||||
mask and albedo always agree.
|
||||
* logo-capable chest UV2 — base.author_logo_uv with the chest box rescaled
|
||||
(LOGO_SCALE, aspect preserved) and dropped below the front scoop so the
|
||||
decal never crosses the neckline.
|
||||
|
||||
Region convention (spec): collar band=R (tint_0), body=G (tint_1),
|
||||
armhole trim=B (tint_2). A unused. Bright default tints live in the manifest;
|
||||
the albedo is a bright neutral so tints carry the colour (style pin: modern,
|
||||
texture carries identity, flat toon-friendly).
|
||||
|
||||
Run (per-body only — offset shells author per body, Q-060):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_tank_top.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/tank_top \
|
||||
[--bodies average_m,child,...] [--offset 0.012] \
|
||||
[--front-scoop-frac 0.28] [--strap-out-frac 0.80] [--trim-w 0.020]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb + <out_dir>/<body>_mask.png
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060
|
||||
(per-body authoring for offset shells).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import blender_author_offset_shell as base # noqa: E402
|
||||
import blender_author_buttondown as bdn # noqa: E402 (per-pixel bake helpers)
|
||||
import blender_author_denim_pants as denim # noqa: E402 (boundary weld)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Garment parameters (average_m metres; scaled per body by bone landmarks)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper"]
|
||||
|
||||
OFFSET_M = 0.012 # snug per-body standoff (Q-060 ideal)
|
||||
CLOTH_THICKNESS_M = 0.004 # jersey-weight cloth, same as the tee
|
||||
|
||||
# Cut proportions. x fractions are of shoulder |x| (upperarm head); scoop
|
||||
# drops are fractions of the spine_01->neck_01 span — the same landmark
|
||||
# language as base.derive_thresholds.
|
||||
STRAP_IN_FRAC = 0.54 # inner strap edge |x|
|
||||
STRAP_OUT_FRAC = 0.80 # outer strap edge |x| (armhole starts here)
|
||||
FRONT_SCOOP_DROP_FRAC = 0.28 # front neckline below the neck head
|
||||
BACK_SCOOP_DROP_FRAC = 0.13 # back neckline below the neck head
|
||||
MIN_STRAP_W_M = 0.024 # never squeeze the strap narrower than this
|
||||
RING_MARGIN_M = 0.008 # clearance under/inside a measured jagged ring
|
||||
SCOOP_BLEND_Y_M = 0.020 # front->back scoop height blend half-width
|
||||
|
||||
# Open-rim treatment.
|
||||
SMOOTH_ITERS = 12 # Laplacian passes on scoop/armhole boundary loops
|
||||
SMOOTH_LAM = 0.5
|
||||
HEM_RING_SPAN_FRAC = 0.15 # boundary verts below spine_lo + f*span = hem ring
|
||||
|
||||
# Painted trim (distances measured to the opening edges, post-offset).
|
||||
TRIM_W_M = 0.020 # collar / armhole binding band width
|
||||
STITCH_W_M = 0.0028 # stitch line half-width
|
||||
HEM_STITCH_UP_M = 0.010 # hem stitch line height above the hem plane
|
||||
LOGO_SCALE = 0.85 # chest box rescale (aspect preserved)
|
||||
LOGO_TOP_GAP_M = 0.020 # logo box top below the front scoop
|
||||
|
||||
MASK_SIZE = 512
|
||||
ALBEDO_SIZE = 1024
|
||||
|
||||
# Bright neutral jersey — the manifest default tints carry the actual colour.
|
||||
FABRIC_RGB = (0.70, 0.71, 0.73)
|
||||
FABRIC_NOISE = 0.025
|
||||
TRIM_MUL = 0.90 # binding bands read slightly denser than the body
|
||||
STITCH_MUL = 0.55 # dark stitch lines
|
||||
ALBEDO_SEED = 1094 # deterministic, distinct from other garments
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[tank-top] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Landmarks + ring probe + cut parameter derivation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TankLandmarks:
|
||||
"""Bone landmarks the proportional parameters scale from."""
|
||||
|
||||
def __init__(self, armature):
|
||||
bones = armature.data.bones
|
||||
ua_l = bones.get("upperarm_l")
|
||||
ua_r = bones.get("upperarm_r")
|
||||
neck = bones.get("neck_01")
|
||||
spine01 = bones.get("spine_01")
|
||||
if not all([ua_l, ua_r, neck, spine01]):
|
||||
raise RuntimeError("landmark bones missing — not the 65-bone rig?")
|
||||
self.shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
self.neck_z = neck.head_local.z
|
||||
self.spine_lo = spine01.head_local.z
|
||||
self.span = self.neck_z - self.spine_lo
|
||||
self.scale = self.shoulder_x / base._REF_SHOULDER_X
|
||||
log(f"landmarks: shoulder_x={self.shoulder_x:.4f} neck_z={self.neck_z:.4f} "
|
||||
f"spine_lo={self.spine_lo:.4f} span={self.span:.4f} scale={self.scale:.3f}")
|
||||
|
||||
|
||||
def probe_rings(shell, lm):
|
||||
"""Measure the natural open-boundary rings of the welded torso shell.
|
||||
|
||||
The segment splitter cuts along weight thresholds, so all three natural
|
||||
boundaries (neck ring, arm rings, hem ring) are jagged teeth. The cut
|
||||
thresholds below are clamped so the neck + arm rings fall entirely inside
|
||||
the deleted zone; the hem ring is flattened to a plane instead.
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
bverts = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
bverts.update(v.index for v in e.verts)
|
||||
hem_test_z = lm.spine_lo + HEM_RING_SPAN_FRAC * lm.span
|
||||
hem, neck, arm = [], [], []
|
||||
for i in bverts:
|
||||
co = bm.verts[i].co
|
||||
if co.z < hem_test_z:
|
||||
hem.append(co.copy())
|
||||
elif abs(co.x) < 0.5 * lm.shoulder_x:
|
||||
neck.append(co.copy())
|
||||
else:
|
||||
arm.append(co.copy())
|
||||
bm.free()
|
||||
if not hem or not neck or not arm:
|
||||
raise RuntimeError(
|
||||
f"ring probe incomplete: hem={len(hem)} neck={len(neck)} arm={len(arm)}")
|
||||
rings = {
|
||||
"hem_min_z": min(c.z for c in hem),
|
||||
"hem_max_z": max(c.z for c in hem),
|
||||
"hem_test_z": hem_test_z,
|
||||
"neck_max_ax": max(abs(c.x) for c in neck),
|
||||
"neck_min_z": min(c.z for c in neck),
|
||||
"arm_min_ax": min(abs(c.x) for c in arm),
|
||||
"arm_min_z": min(c.z for c in arm),
|
||||
}
|
||||
log(f"rings: hem n={len(hem)} z {rings['hem_min_z']:.3f}..{rings['hem_max_z']:.3f}; "
|
||||
f"neck n={len(neck)} |x|<={rings['neck_max_ax']:.3f} z>={rings['neck_min_z']:.3f}; "
|
||||
f"arm n={len(arm)} |x|>={rings['arm_min_ax']:.3f} z>={rings['arm_min_z']:.3f}")
|
||||
return rings
|
||||
|
||||
|
||||
def derive_cut(lm, rings):
|
||||
"""Cut planes from proportional defaults, clamped to swallow the rings."""
|
||||
strap_out = min(STRAP_OUT_FRAC * lm.shoulder_x,
|
||||
rings["arm_min_ax"] - RING_MARGIN_M)
|
||||
strap_in = max(STRAP_IN_FRAC * lm.shoulder_x,
|
||||
rings["neck_max_ax"] + RING_MARGIN_M)
|
||||
min_w = MIN_STRAP_W_M * lm.scale
|
||||
if strap_out - strap_in < min_w:
|
||||
squeezed = strap_out - min_w
|
||||
floor = rings["neck_max_ax"] + 0.004
|
||||
if squeezed < floor:
|
||||
log(f"WARNING: strap squeezed against the neck ring "
|
||||
f"(in={squeezed:.3f} floor={floor:.3f}) — using floor")
|
||||
squeezed = floor
|
||||
strap_in = squeezed
|
||||
back_scoop = min(lm.neck_z - BACK_SCOOP_DROP_FRAC * lm.span,
|
||||
rings["neck_min_z"] - RING_MARGIN_M)
|
||||
front_scoop = min(lm.neck_z - FRONT_SCOOP_DROP_FRAC * lm.span, back_scoop)
|
||||
armhole_z = rings["arm_min_z"] - RING_MARGIN_M
|
||||
params = {
|
||||
"strap_in": strap_in,
|
||||
"strap_out": strap_out,
|
||||
"strap_mid": 0.5 * (strap_in + strap_out),
|
||||
"front_scoop": front_scoop,
|
||||
"back_scoop": back_scoop,
|
||||
"armhole_z": armhole_z,
|
||||
"hem_plane": rings["hem_min_z"],
|
||||
"hem_test_z": rings["hem_test_z"],
|
||||
}
|
||||
log(f"cut: strap |x| {strap_in:.3f}..{strap_out:.3f}, scoop front {front_scoop:.3f} "
|
||||
f"back {back_scoop:.3f}, armhole z>{armhole_z:.3f}, hem plane {params['hem_plane']:.3f}")
|
||||
return params
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: tank cut + hem flatten + boundary smoothing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _scoop_z(y, params):
|
||||
"""Neckline height at this y — front scoop blended into the back scoop."""
|
||||
t = (y * base.FRONT_Y_SIGN) / SCOOP_BLEND_Y_M * 0.5 + 0.5
|
||||
t = min(max(t, 0.0), 1.0)
|
||||
return params["back_scoop"] + (params["front_scoop"] - params["back_scoop"]) * t
|
||||
|
||||
|
||||
def tank_cut(shell, params):
|
||||
"""Delete the neck scoop and the armholes.
|
||||
|
||||
Neck zone: |x| < strap_in AND z above the (front/back blended) scoop.
|
||||
Armhole zone: |x| > strap_out AND z above the underarm plane.
|
||||
Both thresholds were clamped so the jagged natural neck/arm rings fall
|
||||
entirely inside the deleted zone — the only natural boundary that
|
||||
survives is the hem ring.
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
doomed = []
|
||||
for v in bm.verts:
|
||||
x, y, z = v.co.x, v.co.y, v.co.z
|
||||
ax = abs(x)
|
||||
if ax < params["strap_in"] and z > _scoop_z(y, params):
|
||||
doomed.append(v)
|
||||
elif ax > params["strap_out"] and z > params["armhole_z"]:
|
||||
doomed.append(v)
|
||||
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"tank cut removed {len(doomed)} verts; {len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def flatten_hem(shell, params):
|
||||
"""Pull the hem ring's jagged teeth onto one clean plane (denim practice).
|
||||
|
||||
The plane sits at the ring's DEEPEST tooth, so the straightened hem keeps
|
||||
overlapping a pants waistband instead of retreating to the shallowest
|
||||
notch (a tank tucks over the waist; extra length is correct here).
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
hem_idx = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
for v in e.verts:
|
||||
if v.co.z < params["hem_test_z"]:
|
||||
hem_idx.add(v.index)
|
||||
for i in hem_idx:
|
||||
bm.verts[i].co.z = params["hem_plane"]
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened hem ring: {len(hem_idx)} verts -> z={params['hem_plane']:.3f}")
|
||||
|
||||
|
||||
def smooth_open_rims(shell, params, iters=None, lam=SMOOTH_LAM):
|
||||
"""Laplacian-relax the scoop + armhole boundary loops into fair curves.
|
||||
|
||||
Vertex-deletion cuts leave sawtooth edges at mesh resolution; a plane
|
||||
flatten (denim) can't express a curved scoop, so each boundary vert is
|
||||
repeatedly pulled toward the midpoint of its two loop neighbours. Hem
|
||||
verts (already on their plane) are pinned; interior verts never move, so
|
||||
weights/UVs are untouched.
|
||||
"""
|
||||
iters = SMOOTH_ITERS if iters is None else iters
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
nbr = {}
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
a, b = e.verts
|
||||
nbr.setdefault(a.index, []).append(b.index)
|
||||
nbr.setdefault(b.index, []).append(a.index)
|
||||
hem_lim = params["hem_plane"] + 0.001
|
||||
movable = [i for i, ns in nbr.items()
|
||||
if len(ns) == 2 and bm.verts[i].co.z > hem_lim]
|
||||
for _ in range(iters):
|
||||
moved = {}
|
||||
for i in movable:
|
||||
n1, n2 = nbr[i]
|
||||
mid = (bm.verts[n1].co + bm.verts[n2].co) * 0.5
|
||||
moved[i] = bm.verts[i].co.lerp(mid, lam)
|
||||
for i, co in moved.items():
|
||||
bm.verts[i].co = co
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"smoothed {len(movable)} scoop/armhole rim verts ({iters} passes)")
|
||||
|
||||
|
||||
def collect_trim_edges(shell, params):
|
||||
"""Opening-edge vert positions (post-offset) for the distance-based trim.
|
||||
|
||||
Returns (neck_pts, arm_pts) float32 arrays. Hem verts are excluded — the
|
||||
hem gets a painted stitch line, not a tinted band (spec: 3 regions).
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
neck, arm = [], []
|
||||
seen = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) != 1:
|
||||
continue
|
||||
for v in e.verts:
|
||||
if v.index in seen:
|
||||
continue
|
||||
seen.add(v.index)
|
||||
if v.co.z < params["hem_test_z"]:
|
||||
continue
|
||||
if abs(v.co.x) < params["strap_mid"]:
|
||||
neck.append(v.co[:])
|
||||
else:
|
||||
arm.append(v.co[:])
|
||||
bm.free()
|
||||
log(f"trim edges: neckline {len(neck)} verts, armholes {len(arm)} verts")
|
||||
return (np.array(neck, dtype=np.float32),
|
||||
np.array(arm, dtype=np.float32))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-pixel bake: region mask + painted albedo share the distance fields
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _min_dist(pos, pts):
|
||||
"""Min euclidean distance from each row of pos (N,3) to the set pts (K,3)."""
|
||||
if pts.size == 0:
|
||||
return np.full(pos.shape[0], np.inf, dtype=np.float32)
|
||||
d2 = ((pos[:, None, :] - pts[None, :, :]) ** 2).sum(axis=2)
|
||||
return np.sqrt(d2.min(axis=1))
|
||||
|
||||
|
||||
def _classify_px(dn, da, trim_w):
|
||||
"""One-hot RGBA rows: collar band R / armhole trim B / body G."""
|
||||
n = dn.shape[0]
|
||||
rgba = np.zeros((n, 4), dtype=np.float32)
|
||||
is_r = (dn < trim_w) & (dn <= da)
|
||||
is_b = (da < trim_w) & (da < dn)
|
||||
rgba[:, 1] = 1.0
|
||||
rgba[is_r] = (1.0, 0.0, 0.0, 0.0)
|
||||
rgba[is_b] = (0.0, 0.0, 1.0, 0.0)
|
||||
return rgba
|
||||
|
||||
|
||||
def _paint_px(pos, rows, dn, da, dims):
|
||||
"""Painted albedo: binding bands, band stitch lines, hem stitch (in-place)."""
|
||||
mul = np.ones(pos.shape[0], dtype=np.float32)
|
||||
d = np.minimum(dn, da)
|
||||
mul[d < dims["trim_w"]] = TRIM_MUL
|
||||
mul[np.abs(d - dims["trim_w"]) < dims["stitch_w"]] = STITCH_MUL
|
||||
hem_line = np.abs(pos[:, 2] - dims["hem_stitch_z"]) < dims["stitch_w"]
|
||||
mul[hem_line] = STITCH_MUL
|
||||
out = rows * mul[:, None]
|
||||
np.clip(out, 0.0, 1.0, out)
|
||||
return out
|
||||
|
||||
|
||||
def bake_tank_maps(shell, lm, params, neck_pts, arm_pts, mask_path, albedo_path):
|
||||
"""One pass over UV0: bake <body>_mask.png + <body>_base_albedo.png."""
|
||||
dims = {
|
||||
"trim_w": TRIM_W_M * lm.scale,
|
||||
"stitch_w": STITCH_W_M * lm.scale,
|
||||
"hem_stitch_z": params["hem_plane"] + HEM_STITCH_UP_M * lm.scale,
|
||||
}
|
||||
tris = bdn._gather_tris(shell)
|
||||
|
||||
mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32)
|
||||
mbuf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
|
||||
|
||||
rng = np.random.default_rng(ALBEDO_SEED)
|
||||
fabric = np.array(FABRIC_RGB, dtype=np.float32)
|
||||
noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)
|
||||
- 0.5) * 2.0 * FABRIC_NOISE
|
||||
abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
||||
|
||||
for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris:
|
||||
cover = bdn._tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE)
|
||||
if cover is not None:
|
||||
pos = bdn._interp_pos(cover, co_a, co_b, co_c)
|
||||
dn = _min_dist(pos, neck_pts)
|
||||
da = _min_dist(pos, arm_pts)
|
||||
mbuf[cover[0], cover[1], :] = _classify_px(dn, da, dims["trim_w"])
|
||||
cover = bdn._tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE)
|
||||
if cover is not None:
|
||||
pos = bdn._interp_pos(cover, co_a, co_b, co_c)
|
||||
dn = _min_dist(pos, neck_pts)
|
||||
da = _min_dist(pos, arm_pts)
|
||||
abuf[cover[0], cover[1], :] = _paint_px(
|
||||
pos, abuf[cover[0], cover[1], :], dn, da, dims)
|
||||
|
||||
tot = MASK_SIZE * MASK_SIZE
|
||||
log("mask texels: collar={:.1f}% body={:.1f}% armhole={:.1f}%".format(
|
||||
100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot,
|
||||
100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot,
|
||||
100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot))
|
||||
|
||||
def _save(buf, name, path, alpha):
|
||||
arr = buf
|
||||
if not alpha:
|
||||
arr = np.concatenate(
|
||||
[buf, np.ones(buf.shape[:2] + (1,), dtype=np.float32)], axis=2)
|
||||
img = bpy.data.images.new(name, buf.shape[1], buf.shape[0], alpha=alpha)
|
||||
img.pixels.foreach_set(arr.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
_save(mbuf, "tank_region_mask", mask_path, alpha=True)
|
||||
log(f"baked region mask -> {mask_path}")
|
||||
albedo_img = _save(abuf, "tank_base_albedo", albedo_path, alpha=False)
|
||||
log(f"saved painted albedo -> {albedo_path}")
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Author one body
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_tank(body_dir, out_dir, body, offset):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
lm = TankLandmarks(armature)
|
||||
|
||||
denim.weld_boundaries(shell) # required practice: weld seam rings
|
||||
rings = probe_rings(shell, lm)
|
||||
params = derive_cut(lm, rings)
|
||||
|
||||
tank_cut(shell, params)
|
||||
flatten_hem(shell, params)
|
||||
smooth_open_rims(shell, params)
|
||||
|
||||
base.offset_outward(shell, offset)
|
||||
neck_pts, arm_pts = collect_trim_edges(shell, params)
|
||||
base.solidify(shell, CLOTH_THICKNESS_M)
|
||||
|
||||
# Logo UV2: chest box rescaled (aspect preserved) and dropped below the
|
||||
# front scoop so the decal sits fully on cloth.
|
||||
thr = base.derive_thresholds(armature)
|
||||
half_w = (thr["chest_x"][1] - thr["chest_x"][0]) * LOGO_SCALE / 2.0
|
||||
box_h = (thr["chest_z"][1] - thr["chest_z"][0]) * LOGO_SCALE
|
||||
top = params["front_scoop"] - LOGO_TOP_GAP_M * lm.scale
|
||||
thr_logo = dict(thr)
|
||||
thr_logo["chest_x"] = (-half_w, half_w)
|
||||
thr_logo["chest_z"] = (top - box_h, top)
|
||||
base.author_logo_uv(shell, thr_logo)
|
||||
|
||||
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
||||
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
||||
albedo_img = bake_tank_maps(shell, lm, params, neck_pts, arm_pts,
|
||||
mask_path, albedo_path)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Entry
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
global FRONT_SCOOP_DROP_FRAC, STRAP_OUT_FRAC, TRIM_W_M, FABRIC_RGB
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] [--offset M] "
|
||||
"[--front-scoop-frac F] [--strap-out-frac F] [--trim-w M] "
|
||||
"[--base-rgb r,g,b]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--front-scoop-frac" in argv:
|
||||
FRONT_SCOOP_DROP_FRAC = float(argv[argv.index("--front-scoop-frac") + 1])
|
||||
if "--strap-out-frac" in argv:
|
||||
STRAP_OUT_FRAC = float(argv[argv.index("--strap-out-frac") + 1])
|
||||
if "--trim-w" in argv:
|
||||
TRIM_W_M = float(argv[argv.index("--trim-w") + 1])
|
||||
if "--base-rgb" in argv:
|
||||
FABRIC_RGB = tuple(
|
||||
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"per-body mode: {len(bodies)} bodies, offset {offset * 1000:.1f} mm, "
|
||||
f"front scoop {FRONT_SCOOP_DROP_FRAC}, strap out {STRAP_OUT_FRAC}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_tank(body_dir, out_dir, body, offset)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc: # noqa: BLE001 — per-body isolation
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,654 @@
|
||||
"""
|
||||
blender_author_track_jacket.py (T-1089 wave 2 — track_jacket, sport family)
|
||||
|
||||
Authors the TRACK JACKET as per-body offset shells, reusing
|
||||
blender_author_offset_shell.py as a library (scene build, segment join,
|
||||
bone-ratio thresholds, offset, solidify, logo UV2, skinned GLB export).
|
||||
Sport-top sibling of blender_author_outerwear.py (jacket_modern); what track
|
||||
kit needs beyond that script is delivered as reusable parameters, not hacks:
|
||||
|
||||
* SLEEVE STRIPES AS THEIR OWN REGION (the recolorable team stripe): two
|
||||
parallel stripes run along the TOP of each sleeve from the shoulder seam
|
||||
to the cuff. Stripe placement uses a per-|x| arm-axis interpolation from
|
||||
the upperarm/lowerarm bone landmarks (the denim script's per-z leg-axis
|
||||
technique rotated onto the arms), with constant-metric arc widths, so the
|
||||
stripes stay parallel on every body.
|
||||
* Region layout per spec: collar=R, body=G, sleeve stripes=B,
|
||||
cuffs + hem band=A (toon_garment.gdshader channel-blends 4 tints).
|
||||
* TEXEL-level combined bake (denim best practice): every UV0 triangle is
|
||||
rasterized once with barycentric-interpolated 3D positions and ONE
|
||||
analytic feature-field evaluation drives BOTH the painted albedo and the
|
||||
region mask, so paint and regions always agree.
|
||||
* Painted albedo identity (luma-carried, survives any tint): full front
|
||||
ZIP (bright teeth dashes + darker placket + edge stitching) running hem
|
||||
-> through the stand collar; STAND COLLAR raised look via texture
|
||||
shading (base seam ring + cast-shadow band under the collar + brighter
|
||||
gradient toward the collar rim — geometry stays stock); ribbed knit cuff
|
||||
+ hem bands with border stitch lines; stripe edge piping.
|
||||
* Required bottoms/footwear practices adopted for this top
|
||||
(blender_author_denim_pants.py): boundary WELD of coincident
|
||||
segment-seam rings before offsetting (elbow/shoulder/chest seams offset
|
||||
as one surface, no gap rings) and open-rim FLATTENING — the segment
|
||||
splitter leaves ~4.5-7.6 cm jagged teeth at the torso-bottom hem ring,
|
||||
and the vert-threshold wrist cut leaves jagged sleeve ends. The hem ring
|
||||
is pulled UP to its own valley (clean elastic hem, no invented
|
||||
coverage) with a post-solidify residue clamp; the wrist rings are pulled
|
||||
OUT to the nominal cut plane (clean cuff edge over real forearm skin).
|
||||
* LEFT-BREAST logo patch (logo-capable): the base UV2 chest box shifts
|
||||
off-centre — the zip owns the centre line — using the outerwear ratios.
|
||||
|
||||
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_track_jacket.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/track_jacket \
|
||||
[--bodies average_m,child,...] [--offset 0.018] [--wrist-keep 0.90]
|
||||
|
||||
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
||||
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
||||
<out_dir>/<body>_base_albedo.png
|
||||
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
||||
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
||||
|
||||
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Import the base offset-shell module (shared machinery)
|
||||
# --------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
|
||||
base = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(base)
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[track-jacket] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parameters — track_jacket. A sport-top variant should only touch this block.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
GARMENT_ID = "track_jacket"
|
||||
|
||||
SEGMENTS = [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
]
|
||||
|
||||
OFFSET_M = 0.018 # sport shell drape: between the 16 mm hoodie and the
|
||||
# 22 mm casual jacket (per-body construction
|
||||
# guarantees body clearance either way)
|
||||
CLOTH_THICKNESS_M = 0.005 # tricot/poly shell weight
|
||||
WRIST_KEEP_FRAC = 0.90 # fraction of the lowerarm kept (hands show)
|
||||
CUFF_BAND_FRAC = 0.18 # last fraction of the KEPT forearm = knit cuff (A)
|
||||
WELD_DIST = 5e-4 # boundary-ring weld tolerance (0.5 mm)
|
||||
TEX_SIZE = 1024 # albedo + mask resolution (zip teeth need > 512)
|
||||
|
||||
# Stand collar (outerwear-calibrated: taller + wider than the tee band).
|
||||
COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below the neck head
|
||||
COLLAR_X_MULT = 1.15 # slightly wider than the tee band
|
||||
|
||||
# Sleeve stripes — metric widths on average_m, scaled by shoulder ratio.
|
||||
STRIPE_GAP_HALF_M = 0.006 # half-gap between the two stripes (about the top line)
|
||||
STRIPE_W_M = 0.018 # width of each stripe
|
||||
STRIPE_EDGE_W_M = 0.0035 # painted piping line at each stripe border
|
||||
|
||||
# Front zip (hem -> through the collar), slimmer than jacket_modern's placket.
|
||||
ZIP_HALF_FRAC = 0.022 / base._REF_SHOULDER_X # placket half-width / shoulder |x|
|
||||
TEETH_HALF_W = 0.006 # zip teeth strip half-width, metres
|
||||
TEETH_PERIOD = 0.024 # dash period along Z, metres
|
||||
TEETH_DUTY = 0.014 # bright dash length within a period, metres
|
||||
|
||||
# Hem band height as fraction of the garment span (collar_z_min - hem plane).
|
||||
HEM_BAND_FRAC = 0.07
|
||||
RIB_PERIOD_M = 0.009 # knit rib period on cuff/hem bands
|
||||
LINE_W_M = 0.004 # seam / border stitch line half-width
|
||||
|
||||
# Collar raised-look shading.
|
||||
SHADOW_H_FRAC = 0.30 # under-collar cast-shadow band height, x neck_len
|
||||
COLLAR_GRAD = 0.06 # extra luma toward the collar rim (raised read)
|
||||
COLLAR_REACH_MULT = 1.35 # seam/shadow lateral reach, x collar_x_abs
|
||||
|
||||
# Left-breast logo patch (outerwear ratios; centre line belongs to the zip).
|
||||
CHEST_X_LO_FRAC = 0.035 / base._REF_SHOULDER_X
|
||||
CHEST_X_HI_FRAC = 0.115 / base._REF_SHOULDER_X
|
||||
CHEST_Z_LO_FRAC = (1.30 - base._REF_SPINE_LO_Z) / (base._REF_NECK_Z - base._REF_SPINE_LO_Z)
|
||||
CHEST_Z_HI_FRAC = (1.42 - base._REF_SPINE_LO_Z) / (base._REF_NECK_Z - base._REF_SPINE_LO_Z)
|
||||
|
||||
# Painted-albedo luma palette (toon_garment recolors by luma: tint*(luma*1.5+0.2)).
|
||||
BASE_LUMA = 0.55 # tricot shell
|
||||
STRIPE_LUMA = 0.62 # stripes slightly lighter (read under same-tint too)
|
||||
STRIPE_EDGE_LUMA = 0.36 # stripe piping
|
||||
COLLAR_LUMA = 0.57 # stand collar band
|
||||
COLLAR_SEAM_LUMA = 0.30 # collar base seam ring
|
||||
COLLAR_SHADOW_LUMA = 0.42 # cast shadow just under the collar base
|
||||
PLACKET_LUMA = 0.48 # zip placket band
|
||||
ZIP_EDGE_LUMA = 0.34 # placket edge stitching
|
||||
TEETH_LUMA = 0.88 # bright zip teeth dashes
|
||||
TEETH_GAP_LUMA = 0.34 # dark tape between dashes
|
||||
BAND_LUMA = 0.46 # ribbed cuff + hem bands
|
||||
BAND_LINE_LUMA = 0.32 # band border stitch line
|
||||
RIB_DELTA = 0.035 # knit rib darkening within bands
|
||||
ALBEDO_NOISE = 0.02 # woven jitter
|
||||
FABRIC_HUE = np.array([0.97, 0.98, 1.03], dtype=np.float32) # faint cool cast
|
||||
NOISE_SEED = 2094
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Thresholds — base derivation + track-jacket extras from the same armature
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def track_thresholds(armature, wrist_keep):
|
||||
thr = base.derive_thresholds(armature)
|
||||
bones = armature.data.bones
|
||||
neck = bones.get("neck_01")
|
||||
ua_l = bones.get("upperarm_l")
|
||||
ua_r = bones.get("upperarm_r")
|
||||
la_l = bones.get("lowerarm_l")
|
||||
spine01 = bones.get("spine_01")
|
||||
|
||||
if ua_l and ua_r:
|
||||
shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0
|
||||
else:
|
||||
shoulder_x = base._REF_SHOULDER_X
|
||||
thr["sh"] = shoulder_x / base._REF_SHOULDER_X
|
||||
|
||||
# Taller, wider stand collar.
|
||||
if neck:
|
||||
neck_len = neck.tail_local.z - neck.head_local.z
|
||||
thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len
|
||||
thr["neck_len"] = neck_len
|
||||
else:
|
||||
thr["neck_len"] = base._REF_NECK_LEN
|
||||
thr["collar_x_abs"] = thr["collar_x_abs"] * COLLAR_X_MULT
|
||||
|
||||
# Front zip placket.
|
||||
thr["zip_half_x"] = shoulder_x * ZIP_HALF_FRAC
|
||||
|
||||
# Wrist cut planes + knit cuff start on the lowerarm bones.
|
||||
cut_planes = []
|
||||
cuff_abs = []
|
||||
for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]:
|
||||
b = bones.get(bone_name)
|
||||
if b is None:
|
||||
log(f"WARNING: bone {bone_name} missing — sleeve left full-length")
|
||||
continue
|
||||
head_x = b.head_local.x
|
||||
tail_x = b.tail_local.x
|
||||
cut_x = head_x + wrist_keep * (tail_x - head_x)
|
||||
band_x = head_x + (wrist_keep - CUFF_BAND_FRAC) * (tail_x - head_x)
|
||||
cut_planes.append((sign, cut_x))
|
||||
cuff_abs.append(abs(band_x))
|
||||
log(f"wrist cut {bone_name}: keep |x| up to {cut_x:.3f} "
|
||||
f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {band_x:.3f}")
|
||||
thr["wrist_cut_planes"] = cut_planes
|
||||
thr["cuff_x_abs"] = min(cuff_abs) if cuff_abs else 1e9
|
||||
|
||||
# Per-|x| arm axis (shoulder -> elbow -> wrist) for stripe placement —
|
||||
# the denim per-z leg-axis technique rotated onto the arms. The rig is
|
||||
# x-mirrored, so the left-arm landmarks serve both sides via |x|.
|
||||
if ua_l and la_l:
|
||||
pts = [ua_l.head_local, la_l.head_local, la_l.tail_local]
|
||||
order = np.argsort([abs(p.x) for p in pts])
|
||||
thr["arm_x"] = np.array([abs(pts[i].x) for i in order])
|
||||
thr["arm_y"] = np.array([pts[i].y for i in order])
|
||||
thr["arm_z"] = np.array([pts[i].z for i in order])
|
||||
else:
|
||||
log("WARNING: arm landmark bones missing — stripes disabled")
|
||||
thr["arm_x"] = None
|
||||
|
||||
# Left-breast logo patch (replaces the base full-chest box).
|
||||
thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC)
|
||||
if neck and spine01:
|
||||
spine_lo = spine01.head_local.z
|
||||
span = neck.head_local.z - spine_lo
|
||||
thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span,
|
||||
spine_lo + CHEST_Z_HI_FRAC * span)
|
||||
|
||||
log(f"track thresholds: collar z>={thr['collar_z_min']:.3f} "
|
||||
f"|x|<{thr['collar_x_abs']:.3f} zip half {thr['zip_half_x']:.3f} "
|
||||
f"cuff |x|>={thr['cuff_x_abs']:.3f} sh {thr['sh']:.3f} "
|
||||
f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) "
|
||||
f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})")
|
||||
return thr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Geometry: weld + wrist cut + open-rim flattening (denim required practices)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def weld_boundaries(shell):
|
||||
"""Merge coincident segment-boundary verts so the offset can't open cracks."""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
before = len(bm.verts)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_DIST)
|
||||
merged = before - len(bm.verts)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"welded segment boundaries: {merged} verts merged "
|
||||
f"({before} -> {len(me.vertices)})")
|
||||
|
||||
|
||||
def wrist_cut(shell, thr):
|
||||
"""Delete forearm verts beyond the wrist plane on each side."""
|
||||
cut_planes = thr.get("wrist_cut_planes", [])
|
||||
if not cut_planes:
|
||||
log("WARNING: no wrist cut planes — sleeves stay full length")
|
||||
return
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(shell.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
to_delete = []
|
||||
for v in bm.verts:
|
||||
for sign, thr_x in cut_planes:
|
||||
if sign > 0 and v.co.x > thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
if sign < 0 and v.co.x < thr_x:
|
||||
to_delete.append(v)
|
||||
break
|
||||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||||
bm.to_mesh(shell.data)
|
||||
bm.free()
|
||||
shell.data.update()
|
||||
log(f"wrist cut removed {len(to_delete)} verts; "
|
||||
f"{len(shell.data.vertices)} remain")
|
||||
|
||||
|
||||
def flatten_open_rims(shell, thr):
|
||||
"""Pull the open boundary rings onto clean planes (pre-offset).
|
||||
|
||||
The segment splitter cuts along weight thresholds, so the torso-bottom
|
||||
hem ring is a jagged ring of teeth (the denim script measured ~4.5-7.6 cm
|
||||
across bodies), and the vert-threshold wrist cut leaves jagged sleeve
|
||||
ends. Three rims are cleaned:
|
||||
|
||||
* WRIST rings (|x| >= sleeve_x_abs, per side): pulled OUT to the
|
||||
nominal bone-plane cut — a clean vertical cuff edge; the forearm
|
||||
skin continues well past the plane, so no clip risk is invented.
|
||||
* HEM ring (bottom of the remaining boundary): pulled UP to its own
|
||||
valley (the shallowest notch) — a clean straight elastic hem without
|
||||
inventing coverage below the authored shell.
|
||||
* NECK ring (top): left natural — every wave-1 top ships the natural
|
||||
neckline boundary and the collar band owns that edge visually.
|
||||
|
||||
Only boundary verts move; weights and loop UVs ride along. Returns the
|
||||
hem plane z (needed for the hem band paint + post-solidify clamp).
|
||||
"""
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
boundary = set()
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1: # open boundary
|
||||
boundary.update(v.index for v in e.verts)
|
||||
|
||||
sleeve_x = thr["sleeve_x_abs"]
|
||||
cut_by_sign = {sign: cut_x for sign, cut_x in thr.get("wrist_cut_planes", [])}
|
||||
wrist = [i for i in boundary if abs(bm.verts[i].co.x) >= sleeve_x]
|
||||
torso = [i for i in boundary if abs(bm.verts[i].co.x) < sleeve_x]
|
||||
zs = [bm.verts[i].co.z for i in torso]
|
||||
z_mid = (min(zs) + max(zs)) / 2.0
|
||||
hem = [i for i in torso if bm.verts[i].co.z <= z_mid]
|
||||
neck = [i for i in torso if bm.verts[i].co.z > z_mid]
|
||||
|
||||
moved_wrist = 0
|
||||
for i in wrist:
|
||||
v = bm.verts[i]
|
||||
sign = 1 if v.co.x >= 0.0 else -1
|
||||
if sign in cut_by_sign:
|
||||
v.co.x = cut_by_sign[sign]
|
||||
moved_wrist += 1
|
||||
|
||||
hem_lo = min(bm.verts[i].co.z for i in hem)
|
||||
hem_plane = max(bm.verts[i].co.z for i in hem) # the valley (shallowest notch)
|
||||
for i in hem:
|
||||
bm.verts[i].co.z = hem_plane
|
||||
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"flattened open rims: {moved_wrist} wrist verts -> nominal cut planes; "
|
||||
f"hem ring ({len(hem)} verts, teeth {hem_plane - hem_lo:.3f} m) -> "
|
||||
f"z={hem_plane:.3f}; neck ring left natural ({len(neck)} verts)")
|
||||
return hem_plane
|
||||
|
||||
|
||||
def clamp_hem_residue(shell, hem_plane):
|
||||
"""Post-solidify safety clamp: interior tooth verts still below the hem
|
||||
plane (multi-triangle teeth) get squashed onto it."""
|
||||
me = shell.data
|
||||
n = 0
|
||||
for v in me.vertices:
|
||||
if v.co.z < hem_plane:
|
||||
v.co.z = hem_plane
|
||||
n += 1
|
||||
me.update()
|
||||
if n:
|
||||
log(f"clamped {n} residual hem verts -> {hem_plane:.3f}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Track feature field (texel-level; drives albedo AND mask together)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _paint_texels(X, Y, Z, noise, thr, hem_plane, hem_top):
|
||||
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions.
|
||||
|
||||
ONE feature evaluation drives both outputs (denim practice), so the
|
||||
painted albedo and the region mask always agree.
|
||||
Region priority: cuff/hem band (A) > sleeve stripes (B) > collar (R) > body (G).
|
||||
"""
|
||||
n = X.shape[0]
|
||||
ax = np.abs(X)
|
||||
front = Y * base.FRONT_Y_SIGN > 0.004
|
||||
sh = thr["sh"]
|
||||
|
||||
on_sleeve = ax >= thr["sleeve_x_abs"]
|
||||
in_cuff = ax >= thr["cuff_x_abs"]
|
||||
in_hem = (~on_sleeve) & (Z <= hem_top)
|
||||
czmin = thr["collar_z_min"]
|
||||
in_collar = (~on_sleeve) & (Z >= czmin) & (ax < thr["collar_x_abs"])
|
||||
|
||||
# Sleeve stripes: arc distance from the top line of the per-|x| arm axis.
|
||||
if thr.get("arm_x") is not None:
|
||||
cy = np.interp(ax, thr["arm_x"], thr["arm_y"])
|
||||
cz = np.interp(ax, thr["arm_x"], thr["arm_z"])
|
||||
dy = Y - cy
|
||||
dz = Z - cz
|
||||
r = np.hypot(dy, dz) + 1e-9
|
||||
arc = np.arctan2(np.abs(dy), dz) * r # 0 on the sleeve top line
|
||||
g0 = STRIPE_GAP_HALF_M * sh
|
||||
w = STRIPE_W_M * sh
|
||||
stripe_zone = on_sleeve & ~in_cuff
|
||||
stripe = stripe_zone & (arc >= g0) & (arc <= g0 + w)
|
||||
ew = STRIPE_EDGE_W_M * sh
|
||||
stripe_edge = stripe_zone & (
|
||||
(np.abs(arc - g0) < ew) | (np.abs(arc - (g0 + w)) < ew))
|
||||
else:
|
||||
arc = np.zeros(n, dtype=np.float32)
|
||||
stripe = np.zeros(n, dtype=bool)
|
||||
stripe_edge = np.zeros(n, dtype=bool)
|
||||
|
||||
# --- region mask (collar=R, body=G, stripes=B, cuffs/hem=A) -------------
|
||||
mask = np.zeros((n, 4), dtype=np.float32)
|
||||
is_a = in_cuff | in_hem
|
||||
is_b = stripe & ~is_a
|
||||
is_r = in_collar & ~is_a & ~is_b
|
||||
is_g = ~(is_a | is_b | is_r)
|
||||
mask[is_a, 3] = 1.0
|
||||
mask[is_b, 2] = 1.0
|
||||
mask[is_r, 0] = 1.0
|
||||
mask[is_g, 1] = 1.0
|
||||
|
||||
# --- painted luma detail -------------------------------------------------
|
||||
v = np.full(n, BASE_LUMA, dtype=np.float32)
|
||||
lw = LINE_W_M * sh
|
||||
|
||||
# Stripes + piping.
|
||||
v[stripe] = STRIPE_LUMA
|
||||
v[stripe_edge] = STRIPE_EDGE_LUMA
|
||||
|
||||
# Stand collar raised look: band luma + brighter gradient toward the rim,
|
||||
# cast-shadow band + seam ring at the collar base (texture-carried depth).
|
||||
reach = thr["collar_x_abs"] * COLLAR_REACH_MULT
|
||||
v[in_collar] = COLLAR_LUMA
|
||||
grad = np.clip((Z - czmin) / max(thr["neck_len"], 1e-6), 0.0, 1.0)
|
||||
v[in_collar] += COLLAR_GRAD * grad[in_collar]
|
||||
shadow_h = SHADOW_H_FRAC * thr["neck_len"]
|
||||
under = (~in_collar) & (~on_sleeve) & (ax < reach) \
|
||||
& (Z < czmin) & (Z >= czmin - shadow_h)
|
||||
t = np.clip((czmin - Z) / max(shadow_h, 1e-6), 0.0, 1.0)
|
||||
v[under] = COLLAR_SHADOW_LUMA \
|
||||
+ (BASE_LUMA - COLLAR_SHADOW_LUMA) * t[under]
|
||||
seam = (~on_sleeve) & (np.abs(Z - czmin) < lw) & (ax < reach)
|
||||
v[seam] = COLLAR_SEAM_LUMA
|
||||
|
||||
# Ribbed cuff + hem bands: band luma, knit ribs, border stitch lines.
|
||||
band = in_cuff | in_hem
|
||||
v[band] = BAND_LUMA
|
||||
cuff_rib = in_cuff & (np.mod(arc, RIB_PERIOD_M) < RIB_PERIOD_M * 0.5)
|
||||
hem_rib = in_hem & (np.mod(X, RIB_PERIOD_M) < RIB_PERIOD_M * 0.5)
|
||||
v[cuff_rib | hem_rib] -= RIB_DELTA
|
||||
v[np.abs(ax - thr["cuff_x_abs"]) < lw] = BAND_LINE_LUMA
|
||||
v[(~on_sleeve) & (np.abs(Z - hem_top) < lw)] = BAND_LINE_LUMA
|
||||
|
||||
# Front zip: placket band + edge stitching + dashed teeth, hem -> through
|
||||
# the collar (paint overrides bands/collar luma; regions stay untouched).
|
||||
zh = thr["zip_half_x"]
|
||||
v[front & (ax < zh)] = PLACKET_LUMA
|
||||
v[front & (np.abs(ax - zh) < lw * 0.7)] = ZIP_EDGE_LUMA
|
||||
teeth = front & (ax < TEETH_HALF_W)
|
||||
dash = np.mod(Z, TEETH_PERIOD) < TEETH_DUTY
|
||||
v[teeth & dash] = TEETH_LUMA
|
||||
v[teeth & ~dash] = TEETH_GAP_LUMA
|
||||
|
||||
# --- albedo --------------------------------------------------------------
|
||||
lum = np.clip(v + noise, 0.0, 1.0)
|
||||
alb = np.empty((n, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb[:, c] = np.clip(lum * FABRIC_HUE[c], 0.0, 1.0)
|
||||
alb[:, 3] = 1.0
|
||||
return alb, mask
|
||||
|
||||
|
||||
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, thr,
|
||||
hem_plane, hem_top, W, H):
|
||||
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
||||
evaluate the track feature field, write albedo + mask together."""
|
||||
a, b, c = uvs
|
||||
A, B, C = cos
|
||||
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
||||
bx, by = b.x * (W - 1), b.y * (H - 1)
|
||||
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
||||
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
||||
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
||||
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
||||
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
||||
if minx > maxx or miny > maxy:
|
||||
return
|
||||
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
||||
if abs(denom) < 1e-9:
|
||||
return
|
||||
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
||||
pxg = xs + 0.5
|
||||
pyg = ys + 0.5
|
||||
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
||||
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
||||
w2 = 1.0 - w0 - w1
|
||||
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
||||
if not inside.any():
|
||||
return
|
||||
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
||||
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
||||
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
||||
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
||||
ysin = ys[inside]
|
||||
xsin = xs[inside]
|
||||
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin],
|
||||
thr, hem_plane, hem_top)
|
||||
alb_buf[ysin, xsin] = alb
|
||||
mask_buf[ysin, xsin] = mask
|
||||
|
||||
|
||||
def paint_albedo_and_mask(shell, thr, hem_plane, hem_top, out_dir, body):
|
||||
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
||||
region mask from one shared feature-field evaluation per texel."""
|
||||
W = H = TEX_SIZE
|
||||
rng = np.random.default_rng(NOISE_SEED)
|
||||
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
||||
* 2.0 * ALBEDO_NOISE)
|
||||
|
||||
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
||||
for c in range(3):
|
||||
alb_buf[:, :, c] = np.clip(
|
||||
(BASE_LUMA + noise_buf) * FABRIC_HUE[c], 0.0, 1.0)
|
||||
alb_buf[:, :, 3] = 1.0
|
||||
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
||||
mask_buf[:, :, 1] = 1.0 # background = body green (bleed-safe)
|
||||
|
||||
me = shell.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.faces.ensure_lookup_table()
|
||||
if not len(bm.loops.layers.uv):
|
||||
raise RuntimeError("no UV layer for albedo/mask paint")
|
||||
uv_layer = bm.loops.layers.uv[0]
|
||||
|
||||
tri_count = 0
|
||||
for face in bm.faces:
|
||||
loops = face.loops[:]
|
||||
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
||||
cos = [loop.vert.co.copy() for loop in loops]
|
||||
for i in range(1, len(uvs) - 1):
|
||||
_raster_tri_paint(
|
||||
alb_buf, mask_buf, noise_buf,
|
||||
(uvs[0], uvs[i], uvs[i + 1]),
|
||||
(cos[0], cos[i], cos[i + 1]),
|
||||
thr, hem_plane, hem_top, W, H)
|
||||
tri_count += 1
|
||||
bm.free()
|
||||
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
||||
|
||||
# Honest region tally over the whole mask (texel counts, background excl.
|
||||
# impossible — background is body green by design).
|
||||
tot = W * H
|
||||
counts = {
|
||||
"collar(R)": int((mask_buf[:, :, 0] > 0.5).sum()),
|
||||
"body(G)": int((mask_buf[:, :, 1] > 0.5).sum()),
|
||||
"stripes(B)": int((mask_buf[:, :, 2] > 0.5).sum()),
|
||||
"cuff/hem(A)": int((mask_buf[:, :, 3] > 0.5).sum()),
|
||||
}
|
||||
log("mask texels: " + " ".join(
|
||||
f"{k}={v} ({100.0 * v / tot:.1f}%)" for k, v in counts.items()))
|
||||
|
||||
def _save(buf, name, path):
|
||||
img = bpy.data.images.new(name, W, H, alpha=True)
|
||||
img.pixels.foreach_set(buf.reshape(-1))
|
||||
img.update()
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
return img
|
||||
|
||||
_save(mask_buf, f"{GARMENT_ID}_mask_{body}",
|
||||
os.path.join(out_dir, f"{body}_mask.png"))
|
||||
albedo_img = _save(alb_buf, f"{GARMENT_ID}_albedo_{body}",
|
||||
os.path.join(out_dir, f"{body}_base_albedo.png"))
|
||||
log(f"saved mask -> {body}_mask.png")
|
||||
log(f"saved albedo -> {body}_base_albedo.png")
|
||||
# Repoint the image at the shared sidecar name before export: the glTF
|
||||
# exporter names the embedded image after the file basename, and Godot
|
||||
# extracts it as <glb>_<imagename>.png -> <body>_base_albedo.png (tshirt
|
||||
# convention). The shared file is re-pointed to average_m's at the end.
|
||||
albedo_img.filepath_raw = os.path.join(out_dir, "base_albedo.png")
|
||||
albedo_img.save()
|
||||
return albedo_img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body authoring
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def author_track_jacket(body_dir, out_dir, body, offset, wrist_keep):
|
||||
base.clear_scene()
|
||||
base.COVERED_SEGMENTS = SEGMENTS
|
||||
shell, armature = base.build_covered_mesh(body_dir)
|
||||
thr = track_thresholds(armature, wrist_keep)
|
||||
|
||||
weld_boundaries(shell)
|
||||
wrist_cut(shell, thr)
|
||||
hem_plane = flatten_open_rims(shell, thr)
|
||||
base.offset_outward(shell, offset)
|
||||
base.solidify(shell, CLOTH_THICKNESS_M)
|
||||
clamp_hem_residue(shell, hem_plane)
|
||||
|
||||
hem_top = hem_plane + HEM_BAND_FRAC * (thr["collar_z_min"] - hem_plane)
|
||||
log(f"hem band: z {hem_plane:.3f}..{hem_top:.3f}")
|
||||
|
||||
base.author_logo_uv(shell, thr) # UV2 before bake (bake reads UV0 directly)
|
||||
albedo_img = paint_albedo_and_mask(shell, thr, hem_plane, hem_top,
|
||||
out_dir, body)
|
||||
base.assign_fabric_material(shell, albedo_img)
|
||||
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
||||
"[--offset M] [--wrist-keep F]")
|
||||
sys.exit(1)
|
||||
bodies_root = argv[0]
|
||||
out_dir = argv[1]
|
||||
offset = OFFSET_M
|
||||
wrist_keep = WRIST_KEEP_FRAC
|
||||
if "--offset" in argv:
|
||||
offset = float(argv[argv.index("--offset") + 1])
|
||||
if "--wrist-keep" in argv:
|
||||
wrist_keep = float(argv[argv.index("--wrist-keep") + 1])
|
||||
bodies = base.BODY_TYPES
|
||||
if "--bodies" in argv:
|
||||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
log(f"track-jacket per-body mode: {len(bodies)} bodies, "
|
||||
f"offset {offset*1000:.0f} mm, wrist keep {wrist_keep:.2f}")
|
||||
|
||||
results = []
|
||||
for body in bodies:
|
||||
body_dir = os.path.join(bodies_root, body)
|
||||
log(f"=== {body} ===")
|
||||
if not os.path.isdir(body_dir):
|
||||
results.append((body, "skipped: body dir missing"))
|
||||
continue
|
||||
try:
|
||||
author_track_jacket(body_dir, out_dir, body, offset, wrist_keep)
|
||||
results.append((body, "ok"))
|
||||
except Exception as exc:
|
||||
log(f"ERROR {body}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((body, f"error: {exc}"))
|
||||
|
||||
ref = base.REFERENCE_BODY
|
||||
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
||||
if os.path.isfile(ref_mask):
|
||||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||||
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
||||
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
||||
if os.path.isfile(ref_alb):
|
||||
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
||||
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
||||
|
||||
log("=" * 50)
|
||||
for body, status in results:
|
||||
log(f" {body:12s} {status}")
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
log(f"OK={ok}/{len(results)}")
|
||||
if ok != len(results):
|
||||
sys.exit(1)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
blender_batch_fit_skinned.py (T-1089 gap G1 — the biggest wardrobe gap)
|
||||
|
||||
Fit a reference clothing GLB (authored on average_m) to every body type and
|
||||
export ANIMATABLE skinned variants. This supersedes
|
||||
tooling/blender_surface_deform_batch.py, whose output used export_skins=False
|
||||
(:142) — those variants cannot animate on the shared skeleton, which is how the
|
||||
runtime loads clothing (character_visual.gd:582-594).
|
||||
|
||||
It merges three proven codepaths that had never been combined:
|
||||
* the 11-body loop + headless temp_override Surface-Deform bind + Shrinkwrap
|
||||
fallback + pipeline_log.json — from blender_surface_deform_batch.py
|
||||
* the weight flow — from
|
||||
spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py:160-257
|
||||
Surface Deform bind -> apply -> Data Transfer VGROUP_WEIGHTS
|
||||
(POLYINTERP_NEAREST) from the fitted body -> normalize -> retarget the
|
||||
armature modifier to the body's armature -> export_skins=True
|
||||
* optional Solidify — from tooling/convert_outfit.py (skipped by
|
||||
default; offset-shell references are already solidified)
|
||||
|
||||
Per body type:
|
||||
average_m (REFERENCE_BODY): direct copy of the reference GLB (already correct).
|
||||
others: SD-bind the reference garment to the target body surface, bake the
|
||||
deformed rest shape, transfer + normalise weights from that body, retarget the
|
||||
armature, export a skinned GLB.
|
||||
|
||||
Run:
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_batch_fit_skinned.py -- \
|
||||
<reference_glb> <bodies_dir> <output_dir> \
|
||||
[--only average_m,average_f,...] [--solidify 0.0] [--self-check]
|
||||
|
||||
Output:
|
||||
<output_dir>/<body_type>.glb one skinned variant per fitted body type
|
||||
<output_dir>/pipeline_log.json per-variant method (surface_deform/shrinkwrap/
|
||||
copy) + status, for the review gate
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import glob
|
||||
import shutil
|
||||
import bpy
|
||||
|
||||
BODY_TYPES = [
|
||||
"thin_m", "thin_f", "average_m", "average_f", "muscular_m", "muscular_f",
|
||||
"teen_m", "teen_f", "heavy_m", "heavy_f", "child",
|
||||
]
|
||||
REFERENCE_BODY = "average_m"
|
||||
SHRINKWRAP_OFFSET = 0.002
|
||||
SD_FALLOFF = 4.0 # generous — clothing sits proud of the body (fit_outfits :212)
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[batch-fit] {msg}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Scene helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
bpy.ops.outliner.orphans_purge(do_recursive=True)
|
||||
|
||||
|
||||
def import_glb(path):
|
||||
before = set(bpy.context.scene.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
return [o for o in bpy.context.scene.objects if o not in before]
|
||||
|
||||
|
||||
def is_body_mesh(obj):
|
||||
"""Skinned body-segment mesh — excludes Icosphere debris that rides in GLBs."""
|
||||
return (
|
||||
obj.type == 'MESH'
|
||||
and not obj.name.startswith("Icosphere")
|
||||
and len(obj.vertex_groups) > 0
|
||||
and len(obj.data.vertices) >= 50
|
||||
)
|
||||
|
||||
|
||||
def deselect_all():
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
|
||||
def set_active(obj):
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Body surface (join of skinned segments) + one target armature
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def build_body(bodies_dir, body_type):
|
||||
"""Import all seg_*.glb for a body; return (body_surface_mesh, armature).
|
||||
|
||||
The surface is a join of the skinned segment meshes (keeps merged vertex
|
||||
groups so it can serve as both the Surface-Deform target and the weight
|
||||
source). One armature is kept as the retarget destination; extras dropped.
|
||||
"""
|
||||
body_dir = os.path.join(bodies_dir, body_type)
|
||||
if not os.path.isdir(body_dir):
|
||||
return None, None
|
||||
|
||||
seg_paths = sorted(glob.glob(os.path.join(body_dir, "seg_*.glb")))
|
||||
meshes = []
|
||||
armature = None
|
||||
for p in seg_paths:
|
||||
for o in import_glb(p):
|
||||
if o.type == 'ARMATURE' and armature is None:
|
||||
armature = o
|
||||
elif o.type == 'ARMATURE':
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
elif is_body_mesh(o):
|
||||
meshes.append(o)
|
||||
elif o.type == 'MESH':
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
if not meshes or armature is None:
|
||||
return None, None
|
||||
|
||||
deselect_all()
|
||||
for m in meshes:
|
||||
m.select_set(True)
|
||||
set_active(meshes[0])
|
||||
bpy.ops.object.join()
|
||||
surface = bpy.context.active_object
|
||||
surface.name = f"body_surface_{body_type}"
|
||||
# Detach from armature so it is pure geometry for SD/weight source.
|
||||
for mod in list(surface.modifiers):
|
||||
if mod.type == 'ARMATURE':
|
||||
surface.modifiers.remove(mod)
|
||||
return surface, armature
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Reference garment
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def import_reference_garment(reference_glb):
|
||||
"""Import the reference garment; return its single skinned mesh + armature."""
|
||||
objs = import_glb(reference_glb)
|
||||
meshes = [o for o in objs if is_body_mesh(o)]
|
||||
if not meshes:
|
||||
# offset-shell garments always have vgroups; guard anyway
|
||||
meshes = [o for o in objs if o.type == 'MESH' and len(o.data.vertices) >= 20]
|
||||
armature = next((o for o in objs if o.type == 'ARMATURE'), None)
|
||||
if len(meshes) > 1:
|
||||
deselect_all()
|
||||
for m in meshes:
|
||||
m.select_set(True)
|
||||
set_active(meshes[0])
|
||||
bpy.ops.object.join()
|
||||
return bpy.context.active_object, armature
|
||||
return (meshes[0] if meshes else None), armature
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fitting (Surface Deform + Shrinkwrap fallback)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def try_surface_deform(clothing, body_surface):
|
||||
"""Bind + apply Surface Deform via a headless context override. Returns bool."""
|
||||
clothing.select_set(True)
|
||||
set_active(clothing)
|
||||
mod = clothing.modifiers.new("SurfaceDeformFit", 'SURFACE_DEFORM')
|
||||
mod.target = body_surface
|
||||
mod.falloff = SD_FALLOFF
|
||||
mod_name = mod.name
|
||||
try:
|
||||
with bpy.context.temp_override(
|
||||
active_object=clothing, object=clothing, selected_objects=[clothing]
|
||||
):
|
||||
bpy.ops.object.surfacedeform_bind(modifier=mod_name)
|
||||
except Exception as exc:
|
||||
log(f" SD bind EXCEPTION: {exc}")
|
||||
clothing.modifiers.remove(mod)
|
||||
return False
|
||||
if not mod.is_bound:
|
||||
log(" SD bind did not complete (is_bound=False)")
|
||||
clothing.modifiers.remove(mod)
|
||||
return False
|
||||
deselect_all()
|
||||
clothing.select_set(True)
|
||||
set_active(clothing)
|
||||
bpy.ops.object.modifier_apply(modifier=mod_name)
|
||||
return True
|
||||
|
||||
|
||||
def shrinkwrap_fallback(clothing, body_surface):
|
||||
log(" Shrinkwrap fallback (SD bind failed)")
|
||||
sw = clothing.modifiers.new("ShrinkwrapFit", 'SHRINKWRAP')
|
||||
sw.target = body_surface
|
||||
sw.wrap_method = 'NEAREST_SURFACEPOINT'
|
||||
sw.wrap_mode = 'ON_SURFACE'
|
||||
sw.offset = SHRINKWRAP_OFFSET
|
||||
deselect_all()
|
||||
clothing.select_set(True)
|
||||
set_active(clothing)
|
||||
bpy.ops.object.modifier_apply(modifier=sw.name)
|
||||
|
||||
|
||||
def transfer_weights(clothing, body_surface):
|
||||
"""Data Transfer VGROUP_WEIGHTS from the fitted body (fit_outfits :160-186)."""
|
||||
deselect_all()
|
||||
set_active(clothing)
|
||||
clothing.select_set(True)
|
||||
dt = clothing.modifiers.new("WeightTransfer", 'DATA_TRANSFER')
|
||||
dt.object = body_surface
|
||||
dt.use_vert_data = True
|
||||
dt.data_types_verts = {'VGROUP_WEIGHTS'}
|
||||
dt.vert_mapping = 'POLYINTERP_NEAREST'
|
||||
dt.layers_vgroup_select_src = 'ALL'
|
||||
dt.layers_vgroup_select_dst = 'NAME'
|
||||
bpy.ops.object.datalayout_transfer(modifier=dt.name)
|
||||
bpy.ops.object.modifier_apply(modifier=dt.name)
|
||||
|
||||
|
||||
def normalize_weights(clothing):
|
||||
deselect_all()
|
||||
set_active(clothing)
|
||||
clothing.select_set(True)
|
||||
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
|
||||
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
def retarget_armature(clothing, new_armature):
|
||||
has_arm = False
|
||||
for mod in clothing.modifiers:
|
||||
if mod.type == 'ARMATURE':
|
||||
mod.object = new_armature
|
||||
has_arm = True
|
||||
if not has_arm:
|
||||
mod = clothing.modifiers.new("Armature", 'ARMATURE')
|
||||
mod.object = new_armature
|
||||
clothing.parent = new_armature
|
||||
clothing.matrix_parent_inverse = new_armature.matrix_world.inverted()
|
||||
|
||||
|
||||
def apply_solidify(clothing, thickness):
|
||||
if thickness <= 0.0:
|
||||
return
|
||||
deselect_all()
|
||||
clothing.select_set(True)
|
||||
set_active(clothing)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.normals_make_consistent(inside=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
sol = clothing.modifiers.new("Solidify", 'SOLIDIFY')
|
||||
sol.thickness = thickness
|
||||
sol.offset = 1.0
|
||||
sol.use_rim = True
|
||||
bpy.ops.object.modifier_apply(modifier=sol.name)
|
||||
|
||||
|
||||
def export_variant(clothing, armature, out_path):
|
||||
deselect_all()
|
||||
clothing.select_set(True)
|
||||
armature.select_set(True)
|
||||
set_active(armature)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=out_path,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False, # keep armature modifier for skinning
|
||||
export_animations=False,
|
||||
export_skins=True, # <-- the whole point of G1
|
||||
export_yup=True,
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_materials='EXPORT',
|
||||
export_image_format='AUTO',
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-body processing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def process_body(body_type, reference_glb, bodies_dir, output_dir, solidify_mm):
|
||||
out_path = os.path.join(output_dir, f"{body_type}.glb")
|
||||
log(f"=== {body_type} ===")
|
||||
|
||||
if body_type == REFERENCE_BODY:
|
||||
shutil.copy2(reference_glb, out_path)
|
||||
log(" reference body — direct copy")
|
||||
return {"body_type": body_type, "status": "ok", "method": "copy"}
|
||||
|
||||
clear_scene()
|
||||
clothing, _ref_arm = import_reference_garment(reference_glb)
|
||||
if clothing is None:
|
||||
return {"body_type": body_type, "status": "error", "error": "no garment mesh"}
|
||||
|
||||
body_surface, armature = build_body(bodies_dir, body_type)
|
||||
if body_surface is None:
|
||||
return {"body_type": body_type, "status": "error", "error": "no body surface"}
|
||||
|
||||
if try_surface_deform(clothing, body_surface):
|
||||
method = "surface_deform"
|
||||
else:
|
||||
shrinkwrap_fallback(clothing, body_surface)
|
||||
method = "shrinkwrap"
|
||||
|
||||
transfer_weights(clothing, body_surface)
|
||||
normalize_weights(clothing)
|
||||
retarget_armature(clothing, armature)
|
||||
apply_solidify(clothing, solidify_mm)
|
||||
|
||||
# Drop the body surface so only garment + armature export.
|
||||
bpy.data.objects.remove(body_surface, do_unlink=True)
|
||||
export_variant(clothing, armature, out_path)
|
||||
log(f" exported [{method}] -> {os.path.basename(out_path)}")
|
||||
return {"body_type": body_type, "status": "ok", "method": method}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Self-check (headless smoke test): refit peasant_tunic to average_f
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def self_check(bodies_dir):
|
||||
"""Prove the SD bind + weight flow + skinned export path runs headless.
|
||||
|
||||
Builds average_f's body surface, binds a trivial one-quad plane to it, and
|
||||
confirms the export produces JOINTS_0/WEIGHTS_0 accessors. Non-fatal probe.
|
||||
"""
|
||||
import struct
|
||||
clear_scene()
|
||||
bpy.ops.mesh.primitive_plane_add(size=0.3, location=(0, 0.1, 1.2))
|
||||
plane = bpy.context.active_object
|
||||
body_surface, armature = build_body(bodies_dir, "average_f")
|
||||
if body_surface is None:
|
||||
log("self-check: no average_f body — SKIP")
|
||||
return
|
||||
ok = try_surface_deform(plane, body_surface)
|
||||
log(f"self-check: SD bind {'ok' if ok else 'fell back'}")
|
||||
if not ok:
|
||||
shrinkwrap_fallback(plane, body_surface)
|
||||
transfer_weights(plane, body_surface)
|
||||
normalize_weights(plane)
|
||||
retarget_armature(plane, armature)
|
||||
bpy.data.objects.remove(body_surface, do_unlink=True)
|
||||
out = os.path.join(bpy.app.tempdir, "selfcheck.glb")
|
||||
export_variant(plane, armature, out)
|
||||
with open(out, 'rb') as f:
|
||||
f.read(12)
|
||||
clen = struct.unpack('<I', f.read(4))[0]
|
||||
f.read(4)
|
||||
j = json.loads(f.read(clen))
|
||||
attrs = set()
|
||||
for m in j.get("meshes", []):
|
||||
for pr in m["primitives"]:
|
||||
attrs |= set(pr["attributes"].keys())
|
||||
has_skin = "JOINTS_0" in attrs and "WEIGHTS_0" in attrs
|
||||
log(f"self-check: exported attrs={sorted(attrs)} skinned={has_skin}")
|
||||
log(f"self-check: {'PASS' if has_skin else 'FAIL'}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Entry
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
|
||||
if "--self-check" in argv:
|
||||
# <bodies_dir> is the first positional in self-check mode
|
||||
pos = [a for a in argv if not a.startswith("--")]
|
||||
bodies_dir = pos[0] if pos else "client/assets/characters/bodies"
|
||||
self_check(bodies_dir)
|
||||
return
|
||||
|
||||
if len(argv) < 3:
|
||||
print("Usage: -- <reference_glb> <bodies_dir> <output_dir> "
|
||||
"[--only a,b,c] [--solidify MM]")
|
||||
sys.exit(1)
|
||||
reference_glb, bodies_dir, output_dir = argv[0], argv[1], argv[2]
|
||||
|
||||
only = None
|
||||
if "--only" in argv:
|
||||
only = [s.strip() for s in argv[argv.index("--only") + 1].split(",")]
|
||||
solidify_mm = 0.0
|
||||
if "--solidify" in argv:
|
||||
solidify_mm = float(argv[argv.index("--solidify") + 1])
|
||||
|
||||
if not os.path.isfile(reference_glb):
|
||||
print(f"ERROR: reference not found: {reference_glb}")
|
||||
sys.exit(1)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
types = only if only else BODY_TYPES
|
||||
log(f"reference={reference_glb}")
|
||||
log(f"bodies={bodies_dir}")
|
||||
log(f"output={output_dir}")
|
||||
log(f"types={types} solidify={solidify_mm*1000:.0f}mm")
|
||||
|
||||
results = []
|
||||
for bt in types:
|
||||
if bt != REFERENCE_BODY and not os.path.isdir(os.path.join(bodies_dir, bt)):
|
||||
results.append({"body_type": bt, "status": "skipped",
|
||||
"error": "body dir missing"})
|
||||
continue
|
||||
try:
|
||||
results.append(process_body(bt, reference_glb, bodies_dir,
|
||||
output_dir, solidify_mm))
|
||||
except Exception as exc:
|
||||
log(f" ERROR {bt}: {exc}")
|
||||
results.append({"body_type": bt, "status": "error", "error": str(exc)})
|
||||
|
||||
ok = [r for r in results if r["status"] == "ok"]
|
||||
sd = len([r for r in ok if r.get("method") == "surface_deform"])
|
||||
sw = len([r for r in ok if r.get("method") == "shrinkwrap"])
|
||||
cp = len([r for r in ok if r.get("method") == "copy"])
|
||||
log("=" * 50)
|
||||
for r in results:
|
||||
log(f" {r['body_type']:12s} {r['status'].upper():8s} "
|
||||
f"{r.get('method', r.get('error', ''))}")
|
||||
log(f"OK={len(ok)} (surface_deform={sd} shrinkwrap={sw} copy={cp})")
|
||||
if sw:
|
||||
log(f"WARNING: {sw} variant(s) used Shrinkwrap — verify extremities at zoom")
|
||||
|
||||
with open(os.path.join(output_dir, "pipeline_log.json"), 'w') as f:
|
||||
json.dump({
|
||||
"reference": os.path.basename(reference_glb),
|
||||
"bodies_dir": bodies_dir,
|
||||
"variants": {r["body_type"]: {
|
||||
"status": r["status"],
|
||||
"method": r.get("method"),
|
||||
"error": r.get("error"),
|
||||
} for r in results},
|
||||
}, f, indent=2)
|
||||
log("wrote pipeline_log.json")
|
||||
|
||||
if any(r["status"] == "error" for r in results):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
blender_compare_bones.py
|
||||
Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- <file_a.glb> <file_b.glb>
|
||||
|
||||
Compares bone names between two GLB/GLTF files. Reports missing or extra bones.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
|
||||
def get_bones(path: str) -> set:
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
return {b.name for b in obj.data.bones}
|
||||
return set()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- <a.glb> <b.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 2:
|
||||
print("ERROR: Need two GLB/GLTF paths.")
|
||||
sys.exit(1)
|
||||
|
||||
path_a, path_b = args[0], args[1]
|
||||
|
||||
print(f"Loading A: {path_a}")
|
||||
bones_a = get_bones(path_a)
|
||||
print(f"A has {len(bones_a)} bones")
|
||||
|
||||
print(f"Loading B: {path_b}")
|
||||
bones_b = get_bones(path_b)
|
||||
print(f"B has {len(bones_b)} bones")
|
||||
|
||||
only_in_a = bones_a - bones_b
|
||||
only_in_b = bones_b - bones_a
|
||||
shared = bones_a & bones_b
|
||||
|
||||
print(f"Shared: {len(shared)}")
|
||||
if only_in_a:
|
||||
print(f"Only in A ({len(only_in_a)}):")
|
||||
for name in sorted(only_in_a):
|
||||
print(f" - {name}")
|
||||
if only_in_b:
|
||||
print(f"Only in B ({len(only_in_b)}):")
|
||||
for name in sorted(only_in_b):
|
||||
print(f" + {name}")
|
||||
if not only_in_a and not only_in_b:
|
||||
print("BONE_MATCH=OK")
|
||||
else:
|
||||
print("BONE_MATCH=MISMATCH")
|
||||
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
blender_create_clothing_refs.py
|
||||
|
||||
Usage:
|
||||
tooling/blender --background --python tooling/blender_create_clothing_refs.py -- \\
|
||||
<bodies_dir> <clothing_output_dir>
|
||||
|
||||
Creates v0.2 placeholder reference clothing meshes for all 5 initial clothing items.
|
||||
Meshes are authored on average_m by importing the relevant body segments, joining
|
||||
them, and offsetting vertices outward along normals to simulate clothing thickness.
|
||||
|
||||
This produces placeholder-quality geometry only — not final art. The geometry reads
|
||||
correctly at gameplay zoom and is sufficient to validate the Surface Deform pipeline
|
||||
and compositor integration.
|
||||
|
||||
Arguments:
|
||||
bodies_dir Directory with one subdir per body type (e.g. client/assets/characters/bodies/)
|
||||
clothing_output_dir Root directory for clothing output (e.g. client/assets/characters/clothing/)
|
||||
|
||||
Output per item in <clothing_output_dir>/<item_id>/:
|
||||
reference.glb -- placeholder clothing mesh on average_m geometry
|
||||
|
||||
Items produced:
|
||||
coveralls_basic -- full-body work suit
|
||||
jacket_utility -- upper body outerwear
|
||||
pants_cargo -- lower body
|
||||
shirt_henley -- upper body inner
|
||||
boots_work -- foot slot
|
||||
|
||||
Decisions: D-162 (clothing pre-baked per body type via Surface Deform)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import bpy
|
||||
import bmesh
|
||||
|
||||
REFERENCE_BODY = "average_m"
|
||||
|
||||
# Each item defines:
|
||||
# segments -- body segment GLBs (from average_m) to import and join
|
||||
# thickness -- outward vertex offset in metres (clothing thickness simulation)
|
||||
CLOTHING_ITEMS = {
|
||||
"coveralls_basic": {
|
||||
"description": "Full-body work suit",
|
||||
"segments": [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
"seg_hand_l", "seg_hand_r",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
"seg_foot_l", "seg_foot_r",
|
||||
],
|
||||
"thickness": 0.006,
|
||||
},
|
||||
"jacket_utility": {
|
||||
"description": "Upper body outerwear",
|
||||
"segments": [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
],
|
||||
"thickness": 0.008, # slightly thicker for outerwear
|
||||
},
|
||||
"pants_cargo": {
|
||||
"description": "Lower body cargo trousers",
|
||||
"segments": [
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
],
|
||||
"thickness": 0.006,
|
||||
},
|
||||
"shirt_henley": {
|
||||
"description": "Upper body inner shirt",
|
||||
"segments": [
|
||||
"seg_torso", "seg_torso_upper",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
],
|
||||
"thickness": 0.004, # thinner for inner layer
|
||||
},
|
||||
"boots_work": {
|
||||
"description": "Work boots (foot slot)",
|
||||
"segments": [
|
||||
"seg_foot_l", "seg_foot_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r", # boot shaft reaches up the lower leg
|
||||
],
|
||||
"thickness": 0.010, # thicker for boots
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def clear_scene():
|
||||
"""Remove all objects and purge orphan data."""
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
bpy.ops.outliner.orphans_purge(do_recursive=True)
|
||||
|
||||
|
||||
def import_glb(path):
|
||||
"""Import a GLB file. Returns newly added objects."""
|
||||
before = set(bpy.context.scene.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
return [o for o in bpy.context.scene.objects if o not in before]
|
||||
|
||||
|
||||
def offset_vertices_along_normals(obj, thickness):
|
||||
"""
|
||||
Move each vertex outward along its computed normal by `thickness` metres.
|
||||
Operates directly on mesh data -- no operators, reliable in headless mode.
|
||||
"""
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
|
||||
# Ensure normals are up to date
|
||||
bm.normal_update()
|
||||
|
||||
for v in bm.verts:
|
||||
v.co += v.normal * thickness
|
||||
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
obj.data.update()
|
||||
|
||||
|
||||
def export_glb(obj, output_path, armature=None):
|
||||
"""Export a mesh (and optionally its armature) as GLB with skinning data."""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
if armature:
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
use_selection=True,
|
||||
export_format='GLB',
|
||||
export_animations=False,
|
||||
export_yup=True,
|
||||
export_image_format='AUTO',
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_skins=armature is not None,
|
||||
export_materials='EXPORT',
|
||||
)
|
||||
|
||||
|
||||
def create_reference_mesh(item_id, config, bodies_dir, output_dir):
|
||||
"""
|
||||
Create the reference.glb for one clothing item on average_m geometry.
|
||||
Preserves bone weights from body segments so clothing deforms with the skeleton.
|
||||
Returns True on success, False on error.
|
||||
"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Item: {item_id} — {config['description']}")
|
||||
|
||||
clear_scene()
|
||||
|
||||
average_m_dir = os.path.join(bodies_dir, REFERENCE_BODY)
|
||||
imported_meshes = []
|
||||
imported_armatures = []
|
||||
missing_segments = []
|
||||
|
||||
for seg_name in config["segments"]:
|
||||
seg_path = os.path.join(average_m_dir, f"{seg_name}.glb")
|
||||
if not os.path.isfile(seg_path):
|
||||
missing_segments.append(seg_name)
|
||||
continue
|
||||
objs = import_glb(seg_path)
|
||||
meshes = [o for o in objs if o.type == 'MESH']
|
||||
armatures = [o for o in objs if o.type == 'ARMATURE']
|
||||
imported_meshes.extend(meshes)
|
||||
imported_armatures.extend(armatures)
|
||||
|
||||
if missing_segments:
|
||||
print(f" NOTE: Missing segments (skipped): {', '.join(missing_segments)}")
|
||||
|
||||
if not imported_meshes:
|
||||
print(f" ERROR: No segment meshes could be imported for {item_id}")
|
||||
return False
|
||||
|
||||
print(f" Imported {len(imported_meshes)} segments, "
|
||||
f"{len(imported_armatures)} armatures "
|
||||
f"({len(missing_segments)} missing)")
|
||||
|
||||
# Use the first armature as the canonical one — all segments share the same rig
|
||||
canonical_armature = imported_armatures[0] if imported_armatures else None
|
||||
|
||||
# Re-parent all meshes to the canonical armature (preserving bone weights)
|
||||
if canonical_armature:
|
||||
for m in imported_meshes:
|
||||
# Clear any existing parent
|
||||
m.parent = None
|
||||
m.matrix_world = m.matrix_world # preserve world transform
|
||||
# Set parent to canonical armature with Armature modifier
|
||||
m.parent = canonical_armature
|
||||
m.parent_type = 'OBJECT'
|
||||
# Ensure Armature modifier exists pointing to canonical armature
|
||||
has_armature_mod = False
|
||||
for mod in m.modifiers:
|
||||
if mod.type == 'ARMATURE':
|
||||
mod.object = canonical_armature
|
||||
has_armature_mod = True
|
||||
if not has_armature_mod:
|
||||
mod = m.modifiers.new(name="Armature", type='ARMATURE')
|
||||
mod.object = canonical_armature
|
||||
|
||||
# Remove duplicate armatures (keep only canonical)
|
||||
for arm in imported_armatures[1:]:
|
||||
bpy.data.objects.remove(arm, do_unlink=True)
|
||||
|
||||
# Join all segments into one mesh (vertex groups / bone weights are preserved by join)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for m in imported_meshes:
|
||||
if m.name in bpy.data.objects:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = imported_meshes[0]
|
||||
if len(imported_meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
|
||||
clothing_obj = bpy.context.active_object
|
||||
clothing_obj.name = f"ref_{item_id}"
|
||||
|
||||
vertex_count_before = len(clothing_obj.data.vertices)
|
||||
print(f" Mesh vertices: {vertex_count_before}")
|
||||
print(f" Vertex groups (bone weights): {len(clothing_obj.vertex_groups)}")
|
||||
|
||||
# Offset vertices outward to simulate clothing thickness
|
||||
thickness = config["thickness"]
|
||||
offset_vertices_along_normals(clothing_obj, thickness)
|
||||
print(f" Applied {thickness*1000:.1f}mm outward offset")
|
||||
|
||||
# Export with armature so clothing is skinned
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, "reference.glb")
|
||||
export_glb(clothing_obj, output_path, armature=canonical_armature)
|
||||
print(f" Exported: {output_path}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python "
|
||||
"tooling/blender_create_clothing_refs.py -- "
|
||||
"<bodies_dir> <clothing_output_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 2:
|
||||
print("ERROR: Provide <bodies_dir> and <clothing_output_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
bodies_dir = args[0]
|
||||
clothing_output_dir = args[1]
|
||||
|
||||
# Preflight: check average_m dir exists
|
||||
avg_m_dir = os.path.join(bodies_dir, REFERENCE_BODY)
|
||||
if not os.path.isdir(avg_m_dir):
|
||||
print(f"ERROR: Reference body directory not found: {avg_m_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\nClothing Reference Mesh Creator (v0.2 placeholder)")
|
||||
print(f" Bodies dir: {bodies_dir}")
|
||||
print(f" Output dir: {clothing_output_dir}")
|
||||
print(f" Reference: {REFERENCE_BODY}")
|
||||
print(f" Items: {len(CLOTHING_ITEMS)}")
|
||||
|
||||
results = {}
|
||||
for item_id, config in CLOTHING_ITEMS.items():
|
||||
item_output_dir = os.path.join(clothing_output_dir, item_id)
|
||||
success = create_reference_mesh(item_id, config, bodies_dir, item_output_dir)
|
||||
results[item_id] = success
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
print("=== Clothing reference creation complete ===")
|
||||
ok_items = [k for k, v in results.items() if v]
|
||||
fail_items = [k for k, v in results.items() if not v]
|
||||
for item_id in CLOTHING_ITEMS:
|
||||
status = "OK" if results[item_id] else "FAILED"
|
||||
print(f" {item_id:20s} {status}")
|
||||
print(f"\n OK: {len(ok_items)} FAILED: {len(fail_items)}")
|
||||
|
||||
if fail_items:
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
blender_extract_armature.py
|
||||
Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- <input.gltf> <output.glb>
|
||||
|
||||
Loads a Quaternius GLTF file, strips all mesh objects and animation data,
|
||||
and exports only the armature as a GLB skeleton file.
|
||||
|
||||
Requirements:
|
||||
- 65-bone hierarchy preserved
|
||||
- No mesh geometry
|
||||
- No animation data
|
||||
- Y-up, -Z forward (glTF default)
|
||||
- 1 unit = 1 meter (Quaternius convention)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
|
||||
def extract_armature(input_path: str, output_path: str) -> None:
|
||||
# Clear the default scene
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
# Import the GLTF/GLB
|
||||
print(f"Loading: {input_path}")
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
|
||||
# Report what was imported
|
||||
all_objects = list(bpy.context.scene.objects)
|
||||
print(f"Imported {len(all_objects)} objects:")
|
||||
for obj in all_objects:
|
||||
print(f" {obj.name} ({obj.type})")
|
||||
|
||||
# Find armature objects
|
||||
armatures = [obj for obj in all_objects if obj.type == 'ARMATURE']
|
||||
if not armatures:
|
||||
print("ERROR: No armature found in the imported file.")
|
||||
sys.exit(1)
|
||||
|
||||
armature = armatures[0]
|
||||
print(f"Armature: {armature.name} — {len(armature.data.bones)} bones")
|
||||
|
||||
if len(armature.data.bones) < 60:
|
||||
print(f"WARNING: Expected ~65 bones, found {len(armature.data.bones)}. Check compatibility.")
|
||||
|
||||
# Remove all non-armature objects (meshes, lights, cameras, empties)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in all_objects:
|
||||
if obj.type != 'ARMATURE':
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.delete()
|
||||
|
||||
# Remove all animation data (skeleton only, no poses)
|
||||
if armature.animation_data:
|
||||
armature.animation_data_clear()
|
||||
for action in list(bpy.data.actions):
|
||||
bpy.data.actions.remove(action)
|
||||
|
||||
# Select only the armature for export
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
|
||||
# Export as GLB — skeleton only, no animations, no meshes
|
||||
print(f"Exporting to: {output_path}")
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
use_selection=True,
|
||||
export_format='GLB',
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
export_yup=True,
|
||||
)
|
||||
|
||||
# Verify: report bone count and names
|
||||
bones = sorted(armature.data.bones, key=lambda b: b.name)
|
||||
print(f"Export complete. {len(bones)} bones:")
|
||||
for bone in bones:
|
||||
print(f" {bone.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- <input.gltf> <output.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 2:
|
||||
print("ERROR: Provide input GLTF/GLB path and output GLB path.")
|
||||
sys.exit(1)
|
||||
|
||||
extract_armature(args[0], args[1])
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
blender_inspect_body.py
|
||||
Usage: tooling/blender --background --python tooling/blender_inspect_body.py -- <input.gltf>
|
||||
|
||||
Lists all mesh objects in a FullBody GLTF: names, vertex counts, vertex group counts.
|
||||
"""
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
args = argv[argv.index("--") + 1:]
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=args[0])
|
||||
print("Scene objects:")
|
||||
for obj in sorted(bpy.context.scene.objects, key=lambda o: o.name):
|
||||
if obj.type == 'MESH':
|
||||
print(f" MESH: {obj.name!r} verts={len(obj.data.vertices)} vgroups={len(obj.vertex_groups)}")
|
||||
else:
|
||||
print(f" {obj.type}: {obj.name!r}")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
blender_inspect_vgroups.py
|
||||
Usage: tooling/blender --background --python tooling/blender_inspect_vgroups.py -- <input.gltf>
|
||||
|
||||
Lists vertex groups in a GLTF full-body mesh. Used to verify bone name coverage
|
||||
for the segmentation script.
|
||||
"""
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
args = argv[argv.index("--") + 1:]
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=args[0])
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'MESH' and obj.vertex_groups:
|
||||
print(f"MESH: {obj.name} — {len(obj.vertex_groups)} vertex groups, {len(obj.data.vertices)} vertices")
|
||||
for vg in sorted(obj.vertex_groups, key=lambda x: x.name):
|
||||
print(f" VG: {vg.name}")
|
||||
break
|
||||
else:
|
||||
print("No skinned mesh found.")
|
||||
for obj in bpy.context.scene.objects:
|
||||
print(f" {obj.name} ({obj.type})")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
blender_list_animations.py
|
||||
Usage: tooling/blender --background --python tooling/blender_list_animations.py -- <input.glb>
|
||||
|
||||
Lists all animation actions in a GLB file.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_list_animations.py -- <input.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 1:
|
||||
print("ERROR: Provide a GLB/GLTF path.")
|
||||
sys.exit(1)
|
||||
|
||||
input_path = args[0]
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
|
||||
actions = list(bpy.data.actions)
|
||||
print("ACTIONS_COUNT=" + str(len(actions)))
|
||||
for action in sorted(actions, key=lambda a: a.name):
|
||||
print("ACTION: " + action.name)
|
||||
|
||||
# Also report armature bone count if present
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
print("ARMATURE_BONES=" + str(len(obj.data.bones)))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
blender_list_bones.py
|
||||
Usage: tooling/blender --background --python tooling/blender_list_bones.py -- <input.glb>
|
||||
|
||||
Lists all bone names in a GLB/GLTF armature, sorted alphabetically.
|
||||
Used to verify bone naming after the January 2026 Quaternius naming update.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_list_bones.py -- <input.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 1:
|
||||
print("ERROR: Provide a GLB/GLTF path.")
|
||||
sys.exit(1)
|
||||
|
||||
input_path = args[0]
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
print(f"ARMATURE: {obj.name} — {len(obj.data.bones)} bones")
|
||||
for b in sorted(obj.data.bones, key=lambda x: x.name):
|
||||
print("BONE: " + b.name)
|
||||
break
|
||||
else:
|
||||
print("ERROR: No armature found in the imported file.")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
blender_process_bodies.py
|
||||
Usage: tooling/blender --background --python tooling/blender_process_bodies.py -- <source_dir> <output_base_dir>
|
||||
|
||||
Drives the full body segmentation pipeline for all 11 body types.
|
||||
Calls blender_segment_body.py logic inline (same Blender session, one pass per body type).
|
||||
|
||||
Source GLTF files (pre-exported from Source tier, Godot - UE format):
|
||||
<source_dir>/Regular_Male_FullBody.gltf -> average_m (18 segs)
|
||||
<source_dir>/Regular_Female_FullBody.gltf -> average_f (18 segs)
|
||||
<source_dir>/Superhero_Male_FullBody.gltf -> muscular_m (18 segs)
|
||||
<source_dir>/Superhero_Female_FullBody.gltf -> muscular_f (18 segs)
|
||||
<source_dir>/Teen_Male_FullBody.gltf -> teen_m (18 segs)
|
||||
<source_dir>/Teen_Female_FullBody.gltf -> teen_f (18 segs)
|
||||
|
||||
Fork body types (vertex-level scale applied before segmentation):
|
||||
Regular_Male + scale (0.82, 1.0, 0.88) -> thin_m
|
||||
Regular_Female + scale (0.82, 1.0, 0.88) -> thin_f
|
||||
Regular_Male + scale (1.20, 1.08, 1.0) -> heavy_m
|
||||
Regular_Female + scale (1.20, 1.08, 1.0) -> heavy_f
|
||||
Teen_Male + scale (0.75, 0.72, 0.75) -> child [gender-neutral]
|
||||
|
||||
Fork body types are approximate (auto-generated from mesh scaling). They produce
|
||||
visually distinct silhouettes but may need artist refinement for final quality.
|
||||
A README is placed in each fork directory marking this status.
|
||||
|
||||
Output: <output_base_dir>/{body_type}/seg_{name}.glb (18 files × 11 types = 198 GLBs)
|
||||
|
||||
Decisions: D-159 (11 body types), D-160 (18 segments), D-164 (Source .blends as starting point)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# We import the segmentation logic from blender_segment_body.py which must be
|
||||
# in the same directory as this script.
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, script_dir)
|
||||
from blender_segment_body import segment_body
|
||||
|
||||
# (gltf_filename, body_type_key, scale, is_fork)
|
||||
BODY_MANIFEST = [
|
||||
# --- Direct body types (segment as-is) ---
|
||||
("Regular_Male_FullBody.gltf", "average_m", (1.0, 1.0, 1.0), False),
|
||||
("Regular_Female_FullBody.gltf", "average_f", (1.0, 1.0, 1.0), False),
|
||||
("Superhero_Male_FullBody.gltf", "muscular_m", (1.0, 1.0, 1.0), False),
|
||||
("Superhero_Female_FullBody.gltf","muscular_f", (1.0, 1.0, 1.0), False),
|
||||
("Teen_Male_FullBody.gltf", "teen_m", (1.0, 1.0, 1.0), False),
|
||||
("Teen_Female_FullBody.gltf", "teen_f", (1.0, 1.0, 1.0), False),
|
||||
# --- Fork body types (auto-scaled from source) ---
|
||||
# thin: narrow/ectomorph — narrower X, slightly shorter Z
|
||||
("Regular_Male_FullBody.gltf", "thin_m", (0.82, 1.0, 0.88), True),
|
||||
("Regular_Female_FullBody.gltf", "thin_f", (0.82, 1.0, 0.88), True),
|
||||
# heavy: wide/endomorph — wider X, slightly taller Y
|
||||
("Regular_Male_FullBody.gltf", "heavy_m", (1.20, 1.08, 1.0), True),
|
||||
("Regular_Female_FullBody.gltf", "heavy_f", (1.20, 1.08, 1.0), True),
|
||||
# child: gender-neutral, forked from Teen Male, scaled down
|
||||
("Teen_Male_FullBody.gltf", "child", (0.75, 0.72, 0.75), True),
|
||||
]
|
||||
|
||||
FORK_README = """\
|
||||
# Fork body type — auto-generated from mesh scaling
|
||||
|
||||
This directory contains **{body_type}** body segments, generated by applying
|
||||
a proportional mesh scale to the source body ({source}):
|
||||
|
||||
Scale: ({sx:.2f}, {sy:.2f}, {sz:.2f})
|
||||
|
||||
These segments are APPROXIMATE. They produce a visually distinct silhouette
|
||||
but are not artist-authored from scratch. Review the proportions and refine
|
||||
the base mesh manually if the auto-scale result is not satisfactory.
|
||||
|
||||
Status: auto-generated, needs artist review
|
||||
Sprint 28 — visual team
|
||||
"""
|
||||
|
||||
|
||||
def write_fork_readme(output_dir, body_type, source, scale):
|
||||
sx, sy, sz = scale
|
||||
readme_path = os.path.join(output_dir, "README.md")
|
||||
with open(readme_path, 'w') as f:
|
||||
f.write(FORK_README.format(body_type=body_type, source=source, sx=sx, sy=sy, sz=sz))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_process_bodies.py -- <source_dir> <output_base_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 2:
|
||||
print("ERROR: Provide <source_dir> and <output_base_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
source_dir = args[0]
|
||||
output_base_dir = args[1]
|
||||
|
||||
# Preflight: check ALL source files exist before starting any processing,
|
||||
# so all missing files are reported at once rather than failing mid-loop.
|
||||
unique_sources = {gltf_filename for (gltf_filename, _, _, _) in BODY_MANIFEST}
|
||||
missing = [
|
||||
os.path.join(source_dir, f)
|
||||
for f in sorted(unique_sources)
|
||||
if not os.path.exists(os.path.join(source_dir, f))
|
||||
]
|
||||
if missing:
|
||||
print("\nERROR: Missing source GLTF files:")
|
||||
for path in missing:
|
||||
print(f" {path}")
|
||||
sys.exit(1)
|
||||
|
||||
results = []
|
||||
|
||||
for (gltf_filename, body_type, scale, is_fork) in BODY_MANIFEST:
|
||||
gltf_path = os.path.join(source_dir, gltf_filename)
|
||||
output_dir = os.path.join(output_base_dir, body_type)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Body type: {body_type} {'[FORK]' if is_fork else '[DIRECT]'}")
|
||||
print(f" Source: {gltf_filename}")
|
||||
if is_fork:
|
||||
print(f" Scale: {scale}")
|
||||
|
||||
exported, skipped = segment_body(gltf_path, output_dir, scale)
|
||||
|
||||
if is_fork:
|
||||
write_fork_readme(output_dir, body_type, gltf_filename, scale)
|
||||
|
||||
results.append((body_type, exported, skipped, is_fork))
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
print("=== Body type segmentation complete ===")
|
||||
total_exported = 0
|
||||
for body_type, exported, skipped, is_fork in results:
|
||||
flag = " [FORK]" if is_fork else ""
|
||||
print(f" {body_type}{flag}: {len(exported)} exported, {len(skipped)} skipped")
|
||||
total_exported += len(exported)
|
||||
print(f" Total GLBs: {total_exported}")
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
blender_process_hair.py
|
||||
Usage: tooling/blender --background --python tooling/blender_process_hair.py -- <source_base_dir> <hair_out_dir> <facial_hair_out_dir> <eyebrows_out_dir>
|
||||
|
||||
Converts Quaternius Source tier hairstyle GLTF files (rigged to Head bone) into
|
||||
production GLBs with mask PNG sidecars.
|
||||
|
||||
Source base dir: .../Hairstyles/Rigged to Head Bone/glTF (Godot -Unreal)/
|
||||
|
||||
Output:
|
||||
hair/ {key}.glb + {key}_mask.png (hair styles)
|
||||
facial_hair/ {key}.glb + {key}_mask.png (beard, moustache, mutton_chops)
|
||||
eyebrows/ {key}.glb (no mask — used as-is, tinted by shader)
|
||||
|
||||
Naming conventions from architecture doc (D-164):
|
||||
Quaternius name -> file key
|
||||
Hair_Bob -> bob
|
||||
Hair_Buns -> buns
|
||||
Hair_BuzzedFemale -> buzzed_female
|
||||
Hair_Long -> long
|
||||
Hair_LongDreads -> long_dreads
|
||||
Hair_Ponytail_2 -> ponytail_f
|
||||
Hair_Balding -> balding
|
||||
Hair_Buzzed -> buzzed
|
||||
Hair_Dreads -> dreads
|
||||
Hair_Mohawk -> mohawk
|
||||
Hair_Ponytail -> ponytail
|
||||
Hair_SimpleParted -> simple_parted
|
||||
Hair_SlickBack -> slick_back
|
||||
Hair_Beard -> beard [facial_hair/]
|
||||
Hair_Moustache -> moustache [facial_hair/]
|
||||
Hair_MuttonChops -> mutton_chops [facial_hair/]
|
||||
Eyebrows_Female -> female [eyebrows/]
|
||||
Eyebrows_Regular -> regular [eyebrows/]
|
||||
Eyebrows_Teen -> teen [eyebrows/]
|
||||
Eyebrows_Thick -> thick [eyebrows/]
|
||||
|
||||
Also produces bald.glb (minimal empty mesh placeholder for 'no hair' slot).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import bpy
|
||||
|
||||
# (source_gltf_relative_to_base, output_key, output_dir_tag)
|
||||
# dir_tag: "hair", "facial_hair", "eyebrows"
|
||||
HAIR_MANIFEST = [
|
||||
# --- Female hairstyles ---
|
||||
("Female/Hair_Bob.gltf", "bob", "hair"),
|
||||
("Female/Hair_Buns.gltf", "buns", "hair"),
|
||||
("Female/Hair_BuzzedFemale.gltf", "buzzed_female", "hair"),
|
||||
("Female/Hair_Long.gltf", "long", "hair"),
|
||||
("Female/Hair_LongDreads.gltf", "long_dreads", "hair"),
|
||||
("Female/Hair_Ponytail_2.gltf", "ponytail_f", "hair"),
|
||||
# --- Male hairstyles ---
|
||||
("Male/Hair_Balding.gltf", "balding", "hair"),
|
||||
("Male/Hair_Buzzed.gltf", "buzzed", "hair"),
|
||||
("Male/Hair_Dreads.gltf", "dreads", "hair"),
|
||||
("Male/Hair_Mohawk.gltf", "mohawk", "hair"),
|
||||
("Male/Hair_Ponytail.gltf", "ponytail", "hair"),
|
||||
("Male/Hair_SimpleParted.gltf", "simple_parted", "hair"),
|
||||
("Male/Hair_SlickBack.gltf", "slick_back", "hair"),
|
||||
# --- Facial hair ---
|
||||
("Male/Hair_Beard.gltf", "beard", "facial_hair"),
|
||||
("Male/Hair_Moustache.gltf", "moustache", "facial_hair"),
|
||||
("Male/Hair_MuttonChops.gltf", "mutton_chops", "facial_hair"),
|
||||
# --- Eyebrows ---
|
||||
("Female/Eyebrows_Female.gltf", "female", "eyebrows"),
|
||||
("Female/Eyebrows_Teen.gltf", "teen", "eyebrows"),
|
||||
("Male/Eyebrows_Regular.gltf", "regular", "eyebrows"),
|
||||
("Male/Eyebrows_Thick.gltf", "thick", "eyebrows"),
|
||||
]
|
||||
|
||||
|
||||
def convert_gltf_to_glb(gltf_path: str, glb_path: str, solidify: bool = False) -> None:
|
||||
"""Import a GLTF file and re-export as GLB with embedded textures."""
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
print(f" Loading: {os.path.basename(gltf_path)}")
|
||||
bpy.ops.import_scene.gltf(filepath=gltf_path)
|
||||
|
||||
objects = list(bpy.context.scene.objects)
|
||||
meshes = [o for o in objects if o.type == 'MESH']
|
||||
arms = [o for o in objects if o.type == 'ARMATURE']
|
||||
print(f" Objects: {len(meshes)} meshes, {len(arms)} armatures")
|
||||
|
||||
# Strip animation data (hair has no runtime animation)
|
||||
for obj in objects:
|
||||
if obj.animation_data:
|
||||
obj.animation_data_clear()
|
||||
for action in list(bpy.data.actions):
|
||||
bpy.data.actions.remove(action)
|
||||
|
||||
# Solidify hair meshes — gives flat hair cards 3D thickness so they don't
|
||||
# clip through the scalp. Makes hair look like a hair-shaped shell.
|
||||
# Only applied to scalp hair, not eyebrows or facial hair.
|
||||
HAIR_THICKNESS = 0.020 # 20mm outward thickness
|
||||
if solidify:
|
||||
for m in meshes:
|
||||
# Skip icospheres and non-hair utility objects
|
||||
if m.name.startswith("Icosphere") or len(m.data.vertices) < 50:
|
||||
continue
|
||||
bpy.context.view_layer.objects.active = m
|
||||
m.select_set(True)
|
||||
# Recalculate normals to ensure consistent outward direction
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.normals_make_consistent(inside=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
# Apply solidify
|
||||
sol_mod = m.modifiers.new(name="Solidify", type='SOLIDIFY')
|
||||
sol_mod.thickness = HAIR_THICKNESS
|
||||
sol_mod.offset = 1.0 # grow outward only
|
||||
sol_mod.use_rim = True # fill edges for closed shell
|
||||
bpy.ops.object.modifier_apply(modifier=sol_mod.name)
|
||||
m.select_set(False)
|
||||
print(f" Solidified: {m.name} ({HAIR_THICKNESS*1000:.0f}mm outward)")
|
||||
|
||||
# Keep armature parenting and skin data intact — the Godot compositor
|
||||
# will load the skinned mesh and add it to the shared skeleton at runtime.
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=glb_path,
|
||||
use_selection=True,
|
||||
export_format='GLB',
|
||||
export_animations=False,
|
||||
export_yup=True,
|
||||
export_image_format='AUTO',
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_skins=True,
|
||||
export_materials='EXPORT',
|
||||
)
|
||||
size = os.path.getsize(glb_path)
|
||||
print(f" GLB exported: {os.path.basename(glb_path)} ({size:,} bytes)")
|
||||
|
||||
|
||||
def make_mask_png(mask_path: str, width: int = 64, height: int = 64) -> None:
|
||||
"""Generate a solid-white mask PNG (entire mesh = tintable region).
|
||||
|
||||
Solid white = fully tintable. Small size is fine — the shader just samples
|
||||
white everywhere. The mask format supports greyscale bands for multi-region
|
||||
recoloring when needed in future.
|
||||
"""
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
# Build a minimal white PNG manually — no Blender image API issues
|
||||
def make_png(w, h):
|
||||
def chunk(ctype, data):
|
||||
c = ctype + data
|
||||
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
|
||||
header = b'\x89PNG\r\n\x1a\n'
|
||||
ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 0, 0, 0, 0)) # 8-bit greyscale
|
||||
raw = b''
|
||||
for _ in range(h):
|
||||
raw += b'\x00' + b'\xff' * w # filter byte + white pixels
|
||||
idat = chunk(b'IDAT', zlib.compress(raw))
|
||||
iend = chunk(b'IEND', b'')
|
||||
return header + ihdr + idat + iend
|
||||
|
||||
with open(mask_path, 'wb') as f:
|
||||
f.write(make_png(width, height))
|
||||
print(f" Mask saved: {os.path.basename(mask_path)} ({width}x{height} white)")
|
||||
|
||||
|
||||
def make_bald_placeholder(glb_path: str) -> None:
|
||||
"""Create a minimal empty GLB for the 'no hair' slot."""
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
# Create a single-vertex mesh with no faces (zero-poly placeholder)
|
||||
mesh_data = bpy.data.meshes.new("bald")
|
||||
mesh_data.from_pydata([(0.0, 0.0, 0.0)], [], [])
|
||||
mesh_data.update()
|
||||
obj = bpy.data.objects.new("bald", mesh_data)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=glb_path,
|
||||
use_selection=True,
|
||||
export_format='GLB',
|
||||
export_animations=False,
|
||||
export_yup=True,
|
||||
export_image_format='AUTO',
|
||||
export_materials='EXPORT',
|
||||
)
|
||||
size = os.path.getsize(glb_path)
|
||||
print(f" bald.glb placeholder ({size:,} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_process_hair.py -- <source_base_dir> <hair_out_dir> <facial_hair_out_dir> <eyebrows_out_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 4:
|
||||
print("ERROR: Provide source_base_dir, hair_out_dir, facial_hair_out_dir, eyebrows_out_dir")
|
||||
sys.exit(1)
|
||||
|
||||
source_base = args[0]
|
||||
hair_out = args[1]
|
||||
fhair_out = args[2]
|
||||
eyebrows_out = args[3]
|
||||
|
||||
out_dirs = {"hair": hair_out, "facial_hair": fhair_out, "eyebrows": eyebrows_out}
|
||||
for d in out_dirs.values():
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
for (gltf_rel, key, tag) in HAIR_MANIFEST:
|
||||
gltf_path = os.path.join(source_base, gltf_rel)
|
||||
out_dir = out_dirs[tag]
|
||||
|
||||
if not os.path.exists(gltf_path):
|
||||
errors.append(f" MISSING: {gltf_path}")
|
||||
continue
|
||||
|
||||
print(f"\n[{tag}] {key}")
|
||||
glb_path = os.path.join(out_dir, key + ".glb")
|
||||
# Only solidify scalp hair — not facial hair or eyebrows
|
||||
convert_gltf_to_glb(gltf_path, glb_path, solidify=(tag == "hair"))
|
||||
|
||||
# Eyebrows: no mask (tinted directly by shader, no recolor mask needed)
|
||||
if tag != "eyebrows":
|
||||
mask_path = os.path.join(out_dir, key + "_mask.png")
|
||||
make_mask_png(mask_path)
|
||||
|
||||
results.append((tag, key, os.path.getsize(glb_path)))
|
||||
|
||||
# bald.glb placeholder
|
||||
print("\n[hair] bald (placeholder)")
|
||||
bald_path = os.path.join(hair_out, "bald.glb")
|
||||
make_bald_placeholder(bald_path)
|
||||
results.append(("hair", "bald", os.path.getsize(bald_path)))
|
||||
|
||||
print("\n=== Hairstyle import complete ===")
|
||||
for tag, key, size in results:
|
||||
print(f" [{tag}] {key}.glb ({size:,} bytes)")
|
||||
|
||||
if errors:
|
||||
print("\nMISSING FILES:")
|
||||
for e in errors:
|
||||
print(e)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
blender_process_heads.py
|
||||
Usage: tooling/blender --background --python tooling/blender_process_heads.py -- <source_dir> <output_dir>
|
||||
|
||||
Processes the 4 Quaternius OnlyHead .blend files into the head template library.
|
||||
|
||||
Source .blends (from <source_dir>):
|
||||
Regular_Female_OnlyHead.blend -> head_001.glb + head_001_mask.png
|
||||
Regular_Male_OnlyHead.blend -> head_002.glb + head_002_mask.png
|
||||
Teen_Female_OnlyHead.blend -> head_003.glb + head_003_mask.png
|
||||
Teen_Male_OnlyHead.blend -> head_004.glb + head_004_mask.png
|
||||
|
||||
Output: <output_dir>/ (typically client/assets/characters/heads/templates/)
|
||||
|
||||
Requirements (D-161, architecture doc):
|
||||
- GLB: BoneAttachment3D-compatible, no animation data, embedded textures
|
||||
- Scale: 1 unit = 1 meter, Y-up, -Z forward (glTF standard)
|
||||
- Mask PNG: greyscale, white = tintable skin tone region (entire head surface = skin)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import bpy
|
||||
|
||||
HEAD_MAPPING = [
|
||||
("Regular_Female_OnlyHead.blend", "head_001"),
|
||||
("Regular_Male_OnlyHead.blend", "head_002"),
|
||||
("Teen_Female_OnlyHead.blend", "head_003"),
|
||||
("Teen_Male_OnlyHead.blend", "head_004"),
|
||||
]
|
||||
|
||||
MASK_RESOLUTION = 1024 # Default; overridden if texture found in .blend
|
||||
|
||||
|
||||
def process_head(blend_path: str, output_dir: str, head_id: str) -> None:
|
||||
glb_path = os.path.join(output_dir, head_id + ".glb")
|
||||
mask_path = os.path.join(output_dir, head_id + "_mask.png")
|
||||
|
||||
print(f"\n--- Processing: {os.path.basename(blend_path)}")
|
||||
print(f" GLB -> {glb_path}")
|
||||
print(f" Mask -> {mask_path}")
|
||||
|
||||
# Load the .blend file
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=blend_path)
|
||||
|
||||
all_objects = list(bpy.context.scene.objects)
|
||||
print(f" Imported {len(all_objects)} objects:")
|
||||
for obj in all_objects:
|
||||
print(f" {obj.name} ({obj.type})")
|
||||
|
||||
meshes = [obj for obj in all_objects if obj.type == 'MESH']
|
||||
if not meshes:
|
||||
print("ERROR: No mesh found in the .blend file")
|
||||
sys.exit(1)
|
||||
|
||||
# Detect texture resolution for mask sizing.
|
||||
# NOTE: This picks the first non-HDR texture found in bpy.data.images, which is
|
||||
# fragile — iteration order is not guaranteed and multiple textures may be present.
|
||||
# If no texture is found, falls back to MASK_RESOLUTION (512x512 default) so the
|
||||
# mask is still generated at a sensible size rather than failing.
|
||||
mask_w = mask_h = MASK_RESOLUTION
|
||||
for img in bpy.data.images:
|
||||
if img.size[0] > 0 and img.size[1] > 0 and not img.name.endswith('.hdr'):
|
||||
print(f" Texture found: {img.name} — {img.size[0]}x{img.size[1]}")
|
||||
mask_w, mask_h = img.size[0], img.size[1]
|
||||
break
|
||||
else:
|
||||
print(f" No texture found — using fallback mask size {mask_w}x{mask_h}")
|
||||
|
||||
# Strip all animation data (heads have no runtime animation — BoneAttachment3D moves them)
|
||||
for obj in all_objects:
|
||||
if obj.animation_data:
|
||||
obj.animation_data_clear()
|
||||
for action in list(bpy.data.actions):
|
||||
bpy.data.actions.remove(action)
|
||||
|
||||
# Note: WGT-* rig widget meshes are automatically excluded by the GLTF exporter
|
||||
# (they have no materials/geometry that exports). No deletion needed.
|
||||
|
||||
# Select all objects for export
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
|
||||
# Export GLB — embedded textures, no animations
|
||||
print(" Exporting GLB...")
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=glb_path,
|
||||
use_selection=True,
|
||||
export_format='GLB',
|
||||
export_animations=False,
|
||||
export_yup=True,
|
||||
export_image_format='AUTO', # embed textures (AUTO embeds for GLB)
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_materials='EXPORT',
|
||||
)
|
||||
print(f" GLB exported ({os.path.getsize(glb_path):,} bytes)")
|
||||
|
||||
# Generate mask PNG — solid white: entire head surface is skin-tone tintable
|
||||
# Format: greyscale (R channel), white = tintable, black = preserve
|
||||
# Using RGBA internally; Blender PNG export honours this correctly.
|
||||
mask_img = bpy.data.images.new(
|
||||
name=head_id + "_mask",
|
||||
width=mask_w,
|
||||
height=mask_h,
|
||||
alpha=False,
|
||||
float_buffer=False,
|
||||
)
|
||||
# Fill with solid white (RGBA 1.0 per channel)
|
||||
mask_img.pixels = [1.0] * (mask_w * mask_h * 4)
|
||||
mask_img.colorspace_settings.name = 'Non-Color'
|
||||
mask_img.file_format = 'PNG'
|
||||
mask_img.filepath_raw = mask_path
|
||||
mask_img.save()
|
||||
print(f" Mask saved ({mask_w}x{mask_h}, solid white = all-skin)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_process_heads.py -- <source_dir> <output_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 2:
|
||||
print("ERROR: Provide <source_dir> and <output_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
source_dir = args[0]
|
||||
output_dir = args[1]
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
for blend_filename, head_id in HEAD_MAPPING:
|
||||
blend_path = os.path.join(source_dir, blend_filename)
|
||||
if not os.path.exists(blend_path):
|
||||
print(f"ERROR: Source file not found: {blend_path}")
|
||||
sys.exit(1)
|
||||
process_head(blend_path, output_dir, head_id)
|
||||
|
||||
print("\n=== All 4 head templates processed ===")
|
||||
for _, head_id in HEAD_MAPPING:
|
||||
glb = os.path.join(output_dir, head_id + ".glb")
|
||||
mask = os.path.join(output_dir, head_id + "_mask.png")
|
||||
print(f" {head_id}.glb ({os.path.getsize(glb):,} bytes)")
|
||||
print(f" {head_id}_mask.png ({os.path.getsize(mask):,} bytes)")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
blender_rebuild_forks.py
|
||||
Usage: tooling/blender --background --python tooling/blender_rebuild_forks.py -- <source_dir> <output_base_dir> [body_type ...]
|
||||
|
||||
Rebuilds ONLY the fork body types (thin_m/f, heavy_m/f, child) through the same
|
||||
segmentation pipeline that produced the healthy six (blender_segment_body.py),
|
||||
using the T-1090 fix: the fork scale is applied to the mesh AND the embedded
|
||||
armature rest pose so each segment stays internally consistent with its own
|
||||
skeleton (required by the shared-skeleton compositor in character_visual.gd).
|
||||
|
||||
Sources (Source-tier Godot - UE exports):
|
||||
Regular_Male_FullBody.gltf + scale (0.82, 1.0, 0.88) -> thin_m
|
||||
Regular_Female_FullBody.gltf + scale (0.82, 1.0, 0.88) -> thin_f
|
||||
Regular_Male_FullBody.gltf + scale (1.20, 1.08, 1.0) -> heavy_m
|
||||
Regular_Female_FullBody.gltf + scale (1.20, 1.08, 1.0) -> heavy_f
|
||||
Teen_Male_FullBody.gltf + scale (0.75, 0.72, 0.75) -> child [gender-neutral]
|
||||
|
||||
The fork scale factors and source mapping are unchanged from
|
||||
blender_process_bodies.py — this driver reuses the same table so the healthy six
|
||||
are never touched. Output layout matches production: <out>/{body_type}/seg_*.glb.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, script_dir)
|
||||
from blender_segment_body import segment_body
|
||||
|
||||
# (gltf_filename, body_type_key, scale, source_label) — forks only.
|
||||
FORK_MANIFEST = [
|
||||
("Regular_Male_FullBody.gltf", "thin_m", (0.82, 1.0, 0.88), "Regular_Male_FullBody.gltf"),
|
||||
("Regular_Female_FullBody.gltf", "thin_f", (0.82, 1.0, 0.88), "Regular_Female_FullBody.gltf"),
|
||||
("Regular_Male_FullBody.gltf", "heavy_m", (1.20, 1.08, 1.0), "Regular_Male_FullBody.gltf"),
|
||||
("Regular_Female_FullBody.gltf", "heavy_f", (1.20, 1.08, 1.0), "Regular_Female_FullBody.gltf"),
|
||||
("Teen_Male_FullBody.gltf", "child", (0.75, 0.72, 0.75), "Teen_Male_FullBody.gltf"),
|
||||
]
|
||||
|
||||
FORK_README = """\
|
||||
# Fork body type — auto-generated from mesh + armature scaling (T-1090)
|
||||
|
||||
This directory contains **{body_type}** body segments, generated by applying a
|
||||
proportional scale to the source body ({source}) mesh AND its armature rest pose
|
||||
together, then segmenting:
|
||||
|
||||
Scale: ({sx:.2f}, {sy:.2f}, {sz:.2f})
|
||||
|
||||
The scale is applied to the mesh vertices and the embedded armature rest pose
|
||||
with the SAME affine (T-1090 fix). This keeps each segment internally consistent
|
||||
with its own skeleton, which the shared-skeleton compositor
|
||||
(character_visual.gd) requires — a mesh-only scale detaches the head and
|
||||
explodes the limbs on relocation.
|
||||
|
||||
Cross-sectional differentiation (thin = narrow, heavy = wide) survives the
|
||||
composite; global height normalises to the shared skeleton (see T-1090 report /
|
||||
Q-060 for the shared-skeleton scale-normalisation note).
|
||||
|
||||
Status: auto-generated (rebuilt T-1090), 18 segments incl. seg_hips
|
||||
"""
|
||||
|
||||
|
||||
def write_fork_readme(output_dir, body_type, source, scale):
|
||||
sx, sy, sz = scale
|
||||
with open(os.path.join(output_dir, "README.md"), "w") as f:
|
||||
f.write(FORK_README.format(body_type=body_type, source=source, sx=sx, sy=sy, sz=sz))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
args = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(args) < 2:
|
||||
print("Usage: -- <source_dir> <output_base_dir> [body_type ...]")
|
||||
sys.exit(1)
|
||||
|
||||
source_dir = args[0]
|
||||
output_base_dir = args[1]
|
||||
only = set(args[2:]) # optional subset filter
|
||||
|
||||
manifest = [m for m in FORK_MANIFEST if not only or m[1] in only]
|
||||
|
||||
unique_sources = {gltf for (gltf, _, _, _) in manifest}
|
||||
missing = [
|
||||
os.path.join(source_dir, f)
|
||||
for f in sorted(unique_sources)
|
||||
if not os.path.exists(os.path.join(source_dir, f))
|
||||
]
|
||||
if missing:
|
||||
print("\nERROR: Missing source GLTF files:")
|
||||
for p in missing:
|
||||
print(f" {p}")
|
||||
sys.exit(1)
|
||||
|
||||
results = []
|
||||
for (gltf_filename, body_type, scale, source_label) in manifest:
|
||||
gltf_path = os.path.join(source_dir, gltf_filename)
|
||||
output_dir = os.path.join(output_base_dir, body_type)
|
||||
print(f"\n{'='*60}\n Fork: {body_type} scale={scale}\n Source: {gltf_filename}")
|
||||
exported, skipped = segment_body(gltf_path, output_dir, scale)
|
||||
write_fork_readme(output_dir, body_type, source_label, scale)
|
||||
results.append((body_type, exported, skipped))
|
||||
|
||||
print(f"\n{'='*60}\n=== Fork rebuild complete ===")
|
||||
for body_type, exported, skipped in results:
|
||||
print(f" {body_type}: {len(exported)} exported, {len(skipped)} skipped")
|
||||
@@ -0,0 +1,492 @@
|
||||
"""
|
||||
blender_segment_body.py
|
||||
Usage:
|
||||
tooling/blender --background --python tooling/blender_segment_body.py -- \
|
||||
<input.gltf> <output_dir> [--scale sx sy sz]
|
||||
|
||||
Segments a Quaternius FullBody GLTF into 18 production GLBs:
|
||||
seg_head, seg_neck, seg_torso, seg_torso_upper,
|
||||
seg_arm_upper_l/r, seg_arm_lower_l/r, seg_hand_l/r,
|
||||
seg_leg_upper_l/r, seg_leg_lower_l/r, seg_foot_l/r,
|
||||
seg_eyes, seg_eyebrows
|
||||
|
||||
Each segment contains the vertices primarily weighted to its bone group
|
||||
plus 1-ring boundary overlap for seam-free deformation.
|
||||
|
||||
Optional --scale sx sy sz applies a vertex-level scale to the mesh before
|
||||
segmentation (for fork body types: thin, heavy, child).
|
||||
|
||||
Outputs: <output_dir>/seg_{name}.glb (18 files total)
|
||||
|
||||
Design: D-160 (18 segments per body type), D-164 (Source .blends as starting point)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import bpy
|
||||
|
||||
# --- Segment → bone vertex group mappings ---
|
||||
# Each segment selects vertices with weight > 0 for ANY listed bone.
|
||||
# 1-ring expansion adds boundary overlap for seam-free deformation (D-160).
|
||||
|
||||
SEGMENT_BONES = {
|
||||
"seg_head": ["Head"],
|
||||
"seg_neck": ["neck_01"],
|
||||
"seg_torso": ["spine_01", "spine_02"],
|
||||
"seg_torso_upper": ["spine_03", "clavicle_l", "clavicle_r"],
|
||||
"seg_hips": ["pelvis"],
|
||||
"seg_arm_upper_l": ["upperarm_l"],
|
||||
"seg_arm_upper_r": ["upperarm_r"],
|
||||
"seg_arm_lower_l": ["lowerarm_l"],
|
||||
"seg_arm_lower_r": ["lowerarm_r"],
|
||||
"seg_hand_l": [
|
||||
"hand_l",
|
||||
"index_01_l", "index_02_l", "index_03_l", "index_04_leaf_l",
|
||||
"middle_01_l", "middle_02_l", "middle_03_l", "middle_04_leaf_l",
|
||||
"pinky_01_l", "pinky_02_l", "pinky_03_l", "pinky_04_leaf_l",
|
||||
"ring_01_l", "ring_02_l", "ring_03_l", "ring_04_leaf_l",
|
||||
"thumb_01_l", "thumb_02_l", "thumb_03_l", "thumb_04_leaf_l",
|
||||
],
|
||||
"seg_hand_r": [
|
||||
"hand_r",
|
||||
"index_01_r", "index_02_r", "index_03_r", "index_04_leaf_r",
|
||||
"middle_01_r", "middle_02_r", "middle_03_r", "middle_04_leaf_r",
|
||||
"pinky_01_r", "pinky_02_r", "pinky_03_r", "pinky_04_leaf_r",
|
||||
"ring_01_r", "ring_02_r", "ring_03_r", "ring_04_leaf_r",
|
||||
"thumb_01_r", "thumb_02_r", "thumb_03_r", "thumb_04_leaf_r",
|
||||
],
|
||||
"seg_leg_upper_l": ["thigh_l"],
|
||||
"seg_leg_upper_r": ["thigh_r"],
|
||||
"seg_leg_lower_l": ["calf_l"],
|
||||
"seg_leg_lower_r": ["calf_r"],
|
||||
"seg_foot_l": ["foot_l", "ball_l", "ball_leaf_l"],
|
||||
"seg_foot_r": ["foot_r", "ball_r", "ball_leaf_r"],
|
||||
}
|
||||
|
||||
# Segments using a dedicated sub-object (not vertex-group-based segmentation)
|
||||
OBJECT_SEGMENTS = {
|
||||
"seg_eyes": "Eyes",
|
||||
"seg_eyebrows": "Eyebrows",
|
||||
}
|
||||
|
||||
# Ordered list for consistent output
|
||||
SEGMENT_ORDER = [
|
||||
"seg_head", "seg_neck",
|
||||
"seg_torso_upper", "seg_torso", "seg_hips",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_arm_lower_l", "seg_arm_lower_r",
|
||||
"seg_hand_l", "seg_hand_r",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r",
|
||||
"seg_leg_lower_l", "seg_leg_lower_r",
|
||||
"seg_foot_l", "seg_foot_r",
|
||||
"seg_eyes", "seg_eyebrows",
|
||||
]
|
||||
|
||||
WEIGHT_THRESHOLD = 0.01 # Minimum weight to count as "belonging" to a bone
|
||||
EXPAND_RINGS = 0 # No overlap — clean segment boundaries for hiding/amputation
|
||||
|
||||
|
||||
def find_objects(scene):
|
||||
"""Identify the main body mesh, eyes, eyebrows, and armature."""
|
||||
armature = None
|
||||
body_mesh = None
|
||||
special = {}
|
||||
|
||||
all_meshes = [o for o in scene.objects if o.type == 'MESH']
|
||||
|
||||
for obj in scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
armature = obj
|
||||
elif obj.type == 'MESH':
|
||||
name_upper = obj.name.upper()
|
||||
if 'EYES' in name_upper and 'BROW' not in name_upper:
|
||||
special['Eyes'] = obj
|
||||
elif 'BROW' in name_upper:
|
||||
special['Eyebrows'] = obj
|
||||
|
||||
# Body mesh = largest mesh not in special set
|
||||
special_objs = set(special.values())
|
||||
candidates = [o for o in all_meshes if o not in special_objs]
|
||||
if candidates:
|
||||
body_mesh = max(candidates, key=lambda o: len(o.data.vertices))
|
||||
|
||||
return body_mesh, special, armature
|
||||
|
||||
|
||||
def apply_scale(mesh_obj, sx, sy, sz):
|
||||
"""Scale mesh vertices in-place (mesh-local space)."""
|
||||
if sx == 1.0 and sy == 1.0 and sz == 1.0:
|
||||
return
|
||||
print(f" Applying mesh scale: ({sx:.3f}, {sy:.3f}, {sz:.3f})")
|
||||
for v in mesh_obj.data.vertices:
|
||||
v.co.x *= sx
|
||||
v.co.y *= sy
|
||||
v.co.z *= sz
|
||||
mesh_obj.data.update()
|
||||
|
||||
|
||||
def apply_fork_scale(body_mesh, special_meshes, armature, sx, sy, sz):
|
||||
"""
|
||||
Scale a fork body (thin/heavy/child) so mesh AND armature stay CONSISTENT.
|
||||
|
||||
This is the load-bearing fix for fork body types (T-1090). The segment GLBs
|
||||
are reparented onto a single SHARED skeleton at runtime (character_visual.gd
|
||||
loads skeleton/armature.glb and drives all segments by bone name). A segment
|
||||
composites coherently ONLY if its mesh matches its own embedded armature's
|
||||
rest pose — the shared skeleton then relocates the whole segment as a rigid
|
||||
unit (this is why the pristine-rig teen body composites fine despite a very
|
||||
different rig; see the T-1090 report).
|
||||
|
||||
The previous behaviour scaled mesh vertices ALONE, leaving the armature at
|
||||
source scale: the head mesh dropped ~0.35 m below the Head bone, limbs flung
|
||||
apart on relocation — the "detached head / spider arms" misrender.
|
||||
|
||||
The scale must be BAKED by Blender via object transform_apply, NOT by poking
|
||||
edit-bone head/tail directly: manual head/tail edits do not recompute bone
|
||||
roll or honour connected-chain constraints, so long chains (arm→hand→fingers,
|
||||
neck→head) accumulate error and still explode. transform_apply rebuilds the
|
||||
bone matrices correctly.
|
||||
|
||||
Method: de-parent meshes (keep transform) so mesh and armature are
|
||||
independent objects sharing the world origin, give each the SAME object
|
||||
scale, then apply. Identical affine about the same origin → mesh verts and
|
||||
bone rest move together; the Armature modifier + vertex groups re-derive a
|
||||
consistent bind, which the glTF exporter bakes into the inverse-bind
|
||||
matrices.
|
||||
"""
|
||||
if sx == 1.0 and sy == 1.0 and sz == 1.0:
|
||||
return
|
||||
print(f" Applying baked fork scale: ({sx:.3f}, {sy:.3f}, {sz:.3f})")
|
||||
meshes = [body_mesh] + [m for m in special_meshes if m is not None]
|
||||
|
||||
if armature.mode != 'OBJECT':
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# De-parent meshes from the armature (keep world transform). The Armature
|
||||
# MODIFIER and vertex groups are untouched — only the parenting relationship
|
||||
# is cleared, so scaling each object about the origin is not double-applied.
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for m in meshes:
|
||||
if m.parent is armature:
|
||||
m.select_set(True)
|
||||
if bpy.context.selected_objects:
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
|
||||
|
||||
# Scale armature + all meshes by the same object scale, then bake.
|
||||
objs = [armature] + meshes
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for o in objs:
|
||||
o.select_set(True)
|
||||
o.scale = (sx, sy, sz)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
|
||||
|
||||
def export_glb(objects, output_path):
|
||||
"""Select the given objects and export as GLB."""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in objects:
|
||||
obj.select_set(True)
|
||||
if objects:
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
use_selection=True,
|
||||
export_format='GLB',
|
||||
export_animations=False,
|
||||
export_yup=True,
|
||||
export_image_format='AUTO',
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_skins=True,
|
||||
export_materials='EXPORT',
|
||||
)
|
||||
|
||||
|
||||
def segment_by_bones(body_mesh, armature, bone_names, output_path):
|
||||
"""
|
||||
Extract a segment from body_mesh based on bone weights.
|
||||
Uses face-based assignment: each face belongs to the segment whose bones
|
||||
have the highest total weight across the face's vertices. No vertices are
|
||||
deleted — only faces that don't belong to this segment are removed.
|
||||
This keeps all boundary vertices intact (shared with neighbors) so there
|
||||
are no gaps, no holes, and no need for caps.
|
||||
"""
|
||||
import bmesh
|
||||
|
||||
# Duplicate the body mesh
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
body_mesh.select_set(True)
|
||||
bpy.context.view_layer.objects.active = body_mesh
|
||||
bpy.ops.object.duplicate(linked=False)
|
||||
dup = bpy.context.active_object
|
||||
|
||||
if dup.mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Get vertex group indices for target bones
|
||||
vg_indices = set()
|
||||
for name in bone_names:
|
||||
vg = dup.vertex_groups.get(name)
|
||||
if vg:
|
||||
vg_indices.add(vg.index)
|
||||
|
||||
if not vg_indices:
|
||||
print(f" WARNING: No vertex groups found for bones {bone_names}")
|
||||
|
||||
# Build BMesh
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(dup.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.faces.ensure_lookup_table()
|
||||
|
||||
deform_layer = bm.verts.layers.deform.verify()
|
||||
|
||||
# Each face belongs to exactly ONE segment — the one whose bones have
|
||||
# the highest total weight across the face's vertices. This prevents
|
||||
# any face from appearing in two segments.
|
||||
#
|
||||
# We compute per-face the sum of weights for EVERY segment's bone set,
|
||||
# then assign the face to the segment with the highest sum. We only
|
||||
# keep faces assigned to THIS segment.
|
||||
|
||||
# Build a map of ALL segments' bone group indices for comparison.
|
||||
# Exclude swappable variants (torso_upper) — they are subsets of their
|
||||
# parent segment and should not compete in exclusive face assignment.
|
||||
# torso_upper gets the same faces as torso, filtered to its bone subset.
|
||||
VARIANT_SEGMENTS = set() # no variants — all segments are independent
|
||||
|
||||
all_segment_vg_indices = {}
|
||||
for seg_name_key, seg_bones in SEGMENT_BONES.items():
|
||||
if seg_name_key in VARIANT_SEGMENTS:
|
||||
continue # skip variants in competition
|
||||
seg_vg = set()
|
||||
for bname in seg_bones:
|
||||
vg = dup.vertex_groups.get(bname)
|
||||
if vg:
|
||||
seg_vg.add(vg.index)
|
||||
all_segment_vg_indices[seg_name_key] = seg_vg
|
||||
|
||||
# For the current segment, use the key from SEGMENT_BONES that matches our bone_names
|
||||
current_seg_key = None
|
||||
for seg_name_key, seg_bones in SEGMENT_BONES.items():
|
||||
if set(seg_bones) == set(bone_names):
|
||||
current_seg_key = seg_name_key
|
||||
break
|
||||
|
||||
if current_seg_key is None:
|
||||
# Fallback: match by vg_indices
|
||||
for seg_name_key, seg_vg in all_segment_vg_indices.items():
|
||||
if seg_vg == vg_indices:
|
||||
current_seg_key = seg_name_key
|
||||
break
|
||||
|
||||
is_variant = current_seg_key in VARIANT_SEGMENTS
|
||||
|
||||
keep_faces = set()
|
||||
if is_variant:
|
||||
# Variant segments (e.g. torso_upper) are subsets of a parent.
|
||||
# Keep only faces where the dominant bone (highest weight vertex)
|
||||
# is exclusively in this variant's bone set, not the parent's
|
||||
# extra bones. For torso_upper (spine_02, spine_03): keep faces
|
||||
# where spine_02/spine_03 outweigh spine_01.
|
||||
for face in bm.faces:
|
||||
variant_w = 0.0
|
||||
total_w = 0.0
|
||||
for v in face.verts:
|
||||
weights = v[deform_layer]
|
||||
for idx, w in weights.items():
|
||||
total_w += w
|
||||
if idx in vg_indices:
|
||||
variant_w += w
|
||||
# Face belongs to variant if variant bones are dominant
|
||||
if total_w > 0 and variant_w / total_w > 0.5:
|
||||
keep_faces.add(face)
|
||||
else:
|
||||
# Primary segments: exclusive assignment via competition
|
||||
for face in bm.faces:
|
||||
best_seg = None
|
||||
best_weight = -1.0
|
||||
for seg_name_key, seg_vg in all_segment_vg_indices.items():
|
||||
total_w = 0.0
|
||||
for v in face.verts:
|
||||
weights = v[deform_layer]
|
||||
for idx in seg_vg:
|
||||
if idx in weights:
|
||||
total_w += weights[idx]
|
||||
if total_w > best_weight:
|
||||
best_weight = total_w
|
||||
best_seg = seg_name_key
|
||||
if best_seg == current_seg_key:
|
||||
keep_faces.add(face)
|
||||
|
||||
print(f" Faces to keep: {len(keep_faces)} / {len(bm.faces)}")
|
||||
|
||||
# Delete faces NOT in keep_faces
|
||||
faces_to_delete = [f for f in bm.faces if f not in keep_faces]
|
||||
bmesh.ops.delete(bm, geom=faces_to_delete, context='FACES')
|
||||
|
||||
# Clean up: remove vertices that have no faces left
|
||||
bm.verts.ensure_lookup_table()
|
||||
orphan_verts = [v for v in bm.verts if not v.link_faces]
|
||||
if orphan_verts:
|
||||
bmesh.ops.delete(bm, geom=orphan_verts, context='VERTS')
|
||||
|
||||
# Write back
|
||||
bm.to_mesh(dup.data)
|
||||
bm.free()
|
||||
dup.data.update()
|
||||
|
||||
remaining = len(dup.data.vertices)
|
||||
print(f" Segment vertices after trim: {remaining}")
|
||||
|
||||
# Export segment + armature
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
dup.select_set(True)
|
||||
if armature:
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active = dup
|
||||
|
||||
export_glb([dup] + ([armature] if armature else []), output_path)
|
||||
|
||||
size = os.path.getsize(output_path)
|
||||
print(f" Exported: {os.path.basename(output_path)} ({size:,} bytes)")
|
||||
|
||||
# Clean up duplicate
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
dup.select_set(True)
|
||||
bpy.ops.object.delete()
|
||||
|
||||
|
||||
def segment_special_object(special_obj, armature, output_path):
|
||||
"""Export a special sub-object (Eyes / Eyebrows) as its own GLB segment."""
|
||||
export_objs = [special_obj]
|
||||
if armature:
|
||||
export_objs.append(armature)
|
||||
export_glb(export_objs, output_path)
|
||||
size = os.path.getsize(output_path)
|
||||
print(f" Exported: {os.path.basename(output_path)} ({size:,} bytes)")
|
||||
|
||||
|
||||
def segment_body(gltf_path, output_dir, scale=(1.0, 1.0, 1.0)):
|
||||
"""Main entry: load GLTF, apply optional scale, produce all 18 segment GLBs."""
|
||||
print(f"\n Loading: {os.path.basename(gltf_path)}")
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=gltf_path)
|
||||
|
||||
body_mesh, special, armature = find_objects(bpy.context.scene)
|
||||
|
||||
if body_mesh is None:
|
||||
print("ERROR: Could not find main body mesh")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" Body mesh: {body_mesh.name!r} ({len(body_mesh.data.vertices)} verts)")
|
||||
print(f" Special: {list(special.keys())}")
|
||||
print(f" Armature: {armature.name if armature else 'NONE'}")
|
||||
|
||||
# Remove utility objects (Icospheres, rig widgets, empties) that are not
|
||||
# the body mesh, eyes, eyebrows, or armature. These can be children of the
|
||||
# armature and would appear in all exported GLBs otherwise.
|
||||
# Using bpy.data.objects.remove() (Python API) instead of ops — operators
|
||||
# have context issues in headless Blender.
|
||||
keepers = set(filter(None, [body_mesh, armature] + list(special.values())))
|
||||
utility_objs = [
|
||||
obj for obj in list(bpy.context.scene.objects)
|
||||
if obj not in keepers
|
||||
]
|
||||
removed = 0
|
||||
for obj in utility_objs:
|
||||
# Clear parent relationship BEFORE removal so the armature stops
|
||||
# treating it as a hierarchy child during GLTF export
|
||||
if obj.parent is not None:
|
||||
obj.parent = None
|
||||
mesh_data = obj.data if obj.type == 'MESH' else None
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
if mesh_data and mesh_data.users == 0:
|
||||
bpy.data.meshes.remove(mesh_data)
|
||||
removed += 1
|
||||
if removed:
|
||||
remaining_names = [o.name for o in bpy.context.scene.objects]
|
||||
print(f" Removed {removed} utility objects. Scene now: {remaining_names}")
|
||||
|
||||
# Apply optional scale transform (for fork body types). Mesh AND armature
|
||||
# are scaled together and baked by Blender so the segment stays internally
|
||||
# consistent when reparented onto the shared runtime skeleton (T-1090 fix —
|
||||
# see apply_fork_scale).
|
||||
sx, sy, sz = scale
|
||||
if scale != (1.0, 1.0, 1.0):
|
||||
apply_fork_scale(body_mesh, list(special.values()), armature, sx, sy, sz)
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Strip animation data
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.animation_data:
|
||||
obj.animation_data_clear()
|
||||
for action in list(bpy.data.actions):
|
||||
bpy.data.actions.remove(action)
|
||||
|
||||
exported = []
|
||||
skipped = []
|
||||
|
||||
for seg_name in SEGMENT_ORDER:
|
||||
output_path = os.path.join(output_dir, seg_name + ".glb")
|
||||
print(f"\n [{seg_name}]")
|
||||
|
||||
if seg_name in OBJECT_SEGMENTS:
|
||||
obj_key = OBJECT_SEGMENTS[seg_name]
|
||||
special_obj = special.get(obj_key)
|
||||
if special_obj is None:
|
||||
print(f" SKIP: no {obj_key!r} object found in scene")
|
||||
skipped.append(seg_name)
|
||||
continue
|
||||
segment_special_object(special_obj, armature, output_path)
|
||||
exported.append(seg_name)
|
||||
|
||||
elif seg_name in SEGMENT_BONES:
|
||||
bone_names = SEGMENT_BONES[seg_name]
|
||||
segment_by_bones(body_mesh, armature, bone_names, output_path)
|
||||
exported.append(seg_name)
|
||||
|
||||
else:
|
||||
print(f" SKIP: unknown segment {seg_name!r}")
|
||||
skipped.append(seg_name)
|
||||
|
||||
return exported, skipped
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_segment_body.py -- <input.gltf> <output_dir> [--scale sx sy sz]")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 2:
|
||||
print("ERROR: Provide <input.gltf> and <output_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
gltf_path = args[0]
|
||||
output_dir = args[1]
|
||||
|
||||
# Optional scale argument
|
||||
scale = (1.0, 1.0, 1.0)
|
||||
if "--scale" in args:
|
||||
idx = args.index("--scale")
|
||||
try:
|
||||
scale = (float(args[idx + 1]), float(args[idx + 2]), float(args[idx + 3]))
|
||||
except (IndexError, ValueError):
|
||||
print("ERROR: --scale requires three floats: sx sy sz")
|
||||
sys.exit(1)
|
||||
|
||||
exported, skipped = segment_body(gltf_path, output_dir, scale)
|
||||
|
||||
print(f"\n=== Segmentation complete: {len(exported)} segments, {len(skipped)} skipped ===")
|
||||
for seg in exported:
|
||||
path = os.path.join(output_dir, seg + ".glb")
|
||||
print(f" {seg}.glb ({os.path.getsize(path):,} bytes)")
|
||||
if skipped:
|
||||
print(f" Skipped: {skipped}")
|
||||
@@ -0,0 +1,459 @@
|
||||
"""
|
||||
blender_surface_deform_batch.py
|
||||
|
||||
Usage:
|
||||
tooling/blender --background --python tooling/blender_surface_deform_batch.py -- \\
|
||||
<reference_glb> <bodies_dir> <output_dir>
|
||||
|
||||
Fits a reference clothing GLB (authored on average_m) to all 11 body types via
|
||||
Blender's Surface Deform modifier and writes fitted GLBs to the output directory.
|
||||
|
||||
Arguments:
|
||||
reference_glb Path to the reference clothing mesh (modeled on average_m).
|
||||
bodies_dir Directory containing one subdirectory per body type, each with
|
||||
seg_*.glb segment files (e.g. client/assets/characters/bodies/).
|
||||
output_dir Directory to write fitted variants. Created if it does not exist.
|
||||
|
||||
Output per run:
|
||||
<output_dir>/thin_m.glb
|
||||
<output_dir>/thin_f.glb
|
||||
<output_dir>/average_m.glb <- direct copy of reference_glb
|
||||
<output_dir>/average_f.glb
|
||||
<output_dir>/muscular_m.glb
|
||||
<output_dir>/muscular_f.glb
|
||||
<output_dir>/teen_m.glb
|
||||
<output_dir>/teen_f.glb
|
||||
<output_dir>/heavy_m.glb
|
||||
<output_dir>/heavy_f.glb
|
||||
<output_dir>/child.glb
|
||||
|
||||
Headless Surface Deform reliability:
|
||||
MEDIUM. The modifier bind operator requires an active object context. This
|
||||
script applies a context override (bpy.context.temp_override) to satisfy
|
||||
the operator. If the bind fails (is_bound == False after the attempt), the
|
||||
script falls back to a Shrinkwrap ON_SURFACE projection and logs a WARNING.
|
||||
|
||||
The Shrinkwrap fallback produces acceptable results for most garments but
|
||||
may cause pinching at extremities on extreme body types (heavy_m, heavy_f).
|
||||
Visually verify all output variants at gameplay zoom before treating this
|
||||
pipeline as production-ready. See VERDICT.md open question: Surface Deform
|
||||
visual quality at extreme body types.
|
||||
|
||||
UV layout:
|
||||
Surface Deform only moves vertex positions -- UVs are not altered.
|
||||
Shrinkwrap fallback also preserves UV layout.
|
||||
|
||||
Decisions: D-162 (clothing pre-baked per body type via Surface Deform)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import bpy
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
BODY_TYPES = [
|
||||
"thin_m",
|
||||
"thin_f",
|
||||
"average_m", # reference copy -- no deform
|
||||
"average_f",
|
||||
"muscular_m",
|
||||
"muscular_f",
|
||||
"teen_m",
|
||||
"teen_f",
|
||||
"heavy_m",
|
||||
"heavy_f",
|
||||
"child",
|
||||
]
|
||||
|
||||
REFERENCE_BODY = "average_m"
|
||||
|
||||
# Body segments to import for the deform surface.
|
||||
# Eyes and eyebrows are excluded -- they are tiny facial sub-objects that
|
||||
# do not affect clothing deformation.
|
||||
DEFORM_SEGMENTS = [
|
||||
"seg_head",
|
||||
"seg_neck",
|
||||
"seg_torso",
|
||||
"seg_torso_upper",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_arm_lower_l",
|
||||
"seg_arm_lower_r",
|
||||
"seg_hand_l",
|
||||
"seg_hand_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r",
|
||||
"seg_leg_lower_l",
|
||||
"seg_leg_lower_r",
|
||||
"seg_foot_l",
|
||||
"seg_foot_r",
|
||||
]
|
||||
|
||||
SHRINKWRAP_OFFSET = 0.002 # Small offset to avoid z-fighting (metres)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Scene helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def clear_scene():
|
||||
"""Remove all objects from the current scene."""
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
# Also purge orphan mesh/material data to avoid memory bloat in batch runs
|
||||
bpy.ops.outliner.orphans_purge(do_recursive=True)
|
||||
|
||||
|
||||
def import_glb(path):
|
||||
"""Import a GLB file. Returns newly created objects."""
|
||||
before = set(bpy.context.scene.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
return [o for o in bpy.context.scene.objects if o not in before]
|
||||
|
||||
|
||||
def get_meshes(objects):
|
||||
"""Filter a list of objects to mesh-type only."""
|
||||
return [o for o in objects if o.type == 'MESH']
|
||||
|
||||
|
||||
def export_glb(obj, output_path):
|
||||
"""
|
||||
Export a single mesh object as GLB.
|
||||
Preserves UVs, normals, and materials. No armature or animations.
|
||||
"""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
use_selection=True,
|
||||
export_format='GLB',
|
||||
export_animations=False,
|
||||
export_yup=True,
|
||||
export_image_format='AUTO',
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_skins=False,
|
||||
export_materials='EXPORT',
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Body surface construction
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def build_body_surface(bodies_dir, body_type):
|
||||
"""
|
||||
Import and join body segments for the given body type into a single
|
||||
mesh object suitable for Surface Deform binding.
|
||||
|
||||
Returns the joined mesh object, or None if the body type directory is
|
||||
missing or contains no usable segments.
|
||||
"""
|
||||
body_dir = os.path.join(bodies_dir, body_type)
|
||||
if not os.path.isdir(body_dir):
|
||||
print(f" WARNING: Body type directory not found: {body_dir}")
|
||||
return None
|
||||
|
||||
imported = []
|
||||
missing = []
|
||||
|
||||
for seg_name in DEFORM_SEGMENTS:
|
||||
seg_path = os.path.join(body_dir, f"{seg_name}.glb")
|
||||
if not os.path.isfile(seg_path):
|
||||
missing.append(seg_name)
|
||||
continue
|
||||
objs = import_glb(seg_path)
|
||||
imported.extend(get_meshes(objs))
|
||||
|
||||
if missing:
|
||||
print(f" NOTE: {len(missing)} segments missing for {body_type} "
|
||||
f"(non-fatal): {', '.join(missing)}")
|
||||
|
||||
if not imported:
|
||||
print(f" ERROR: No segment meshes imported for {body_type}")
|
||||
return None
|
||||
|
||||
print(f" Imported {len(imported)} segments for {body_type}")
|
||||
|
||||
# Select all imported meshes and join them into one
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in imported:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = imported[0]
|
||||
bpy.ops.object.join()
|
||||
|
||||
body_surface = bpy.context.active_object
|
||||
body_surface.name = f"body_surface_{body_type}"
|
||||
return body_surface
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Surface Deform fitting
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def try_surface_deform_bind(clothing_obj, body_surface, mod_name):
|
||||
"""
|
||||
Attempt to bind the Surface Deform modifier using a context override.
|
||||
Returns True if bind succeeded, False otherwise.
|
||||
|
||||
Headless workaround:
|
||||
bpy.ops.object.surfacedeform_bind() requires an active viewport window
|
||||
context. In --background mode, we construct a temporary override that
|
||||
satisfies the operator's active_object requirement. Blender 3.6+ supports
|
||||
this via bpy.context.temp_override().
|
||||
"""
|
||||
clothing_obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = clothing_obj
|
||||
|
||||
try:
|
||||
with bpy.context.temp_override(
|
||||
active_object=clothing_obj,
|
||||
object=clothing_obj,
|
||||
selected_objects=[clothing_obj],
|
||||
):
|
||||
result = bpy.ops.object.surfacedeform_bind(modifier=mod_name)
|
||||
|
||||
mod = clothing_obj.modifiers.get(mod_name)
|
||||
if mod and mod.is_bound:
|
||||
print(" Surface Deform bind: SUCCESS")
|
||||
return True
|
||||
else:
|
||||
print(f" Surface Deform bind: operator returned {result}, "
|
||||
f"is_bound=False — bind did not complete")
|
||||
return False
|
||||
|
||||
except Exception as exc:
|
||||
print(f" Surface Deform bind: EXCEPTION — {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def apply_shrinkwrap_fallback(clothing_obj, body_surface):
|
||||
"""
|
||||
Fallback fitting via Shrinkwrap ON_SURFACE projection.
|
||||
|
||||
Less accurate than Surface Deform (no barycentric interpolation) but
|
||||
reliable in headless mode. May cause pinching at extremities.
|
||||
UV layout is preserved.
|
||||
"""
|
||||
print(" Using Shrinkwrap fallback (Surface Deform bind failed)")
|
||||
sw = clothing_obj.modifiers.new("ShrinkwrapFit", 'SHRINKWRAP')
|
||||
sw.target = body_surface
|
||||
sw.wrap_method = 'NEAREST_SURFACEPOINT'
|
||||
sw.wrap_mode = 'ON_SURFACE'
|
||||
sw.offset = SHRINKWRAP_OFFSET
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
clothing_obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = clothing_obj
|
||||
bpy.ops.object.modifier_apply(modifier=sw.name)
|
||||
|
||||
|
||||
def fit_clothing_to_body(clothing_obj, body_surface):
|
||||
"""
|
||||
Fit clothing_obj to body_surface using Surface Deform, with Shrinkwrap
|
||||
fallback. Returns the method used ('surface_deform' or 'shrinkwrap').
|
||||
"""
|
||||
# Add Surface Deform modifier
|
||||
mod = clothing_obj.modifiers.new("SurfaceDeformFit", 'SURFACE_DEFORM')
|
||||
mod.target = body_surface
|
||||
mod_name = mod.name
|
||||
|
||||
bound = try_surface_deform_bind(clothing_obj, body_surface, mod_name)
|
||||
|
||||
if bound:
|
||||
# Apply the Surface Deform modifier
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
clothing_obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = clothing_obj
|
||||
bpy.ops.object.modifier_apply(modifier=mod_name)
|
||||
return 'surface_deform'
|
||||
else:
|
||||
# Remove the failed Surface Deform modifier before falling back
|
||||
clothing_obj.modifiers.remove(mod)
|
||||
apply_shrinkwrap_fallback(clothing_obj, body_surface)
|
||||
return 'shrinkwrap'
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Main per-body-type processing
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def process_body_type(body_type, reference_glb, bodies_dir, output_dir):
|
||||
"""
|
||||
Process a single body type. Returns a result dict with status info.
|
||||
"""
|
||||
output_path = os.path.join(output_dir, f"{body_type}.glb")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Body type: {body_type}")
|
||||
|
||||
# average_m is a direct copy -- no deform required
|
||||
if body_type == REFERENCE_BODY:
|
||||
print(" Reference body type -- copying reference directly")
|
||||
shutil.copy2(reference_glb, output_path)
|
||||
return {"body_type": body_type, "status": "ok", "method": "copy"}
|
||||
|
||||
clear_scene()
|
||||
|
||||
# --- Import reference clothing ---
|
||||
print(f" Importing reference clothing: {os.path.basename(reference_glb)}")
|
||||
clothing_objs = import_glb(reference_glb)
|
||||
clothing_meshes = get_meshes(clothing_objs)
|
||||
if not clothing_meshes:
|
||||
return {"body_type": body_type, "status": "error",
|
||||
"error": "No mesh found in reference GLB"}
|
||||
|
||||
# If reference GLB contains multiple meshes, join them
|
||||
if len(clothing_meshes) > 1:
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for m in clothing_meshes:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = clothing_meshes[0]
|
||||
bpy.ops.object.join()
|
||||
clothing_obj = bpy.context.active_object
|
||||
clothing_obj.name = "clothing_ref"
|
||||
print(f" Clothing mesh: {len(clothing_obj.data.vertices)} vertices")
|
||||
|
||||
# --- Build body surface ---
|
||||
print(" Building body surface from segments...")
|
||||
body_surface = build_body_surface(bodies_dir, body_type)
|
||||
if body_surface is None:
|
||||
return {"body_type": body_type, "status": "error",
|
||||
"error": "Could not construct body surface"}
|
||||
|
||||
print(f" Body surface: {len(body_surface.data.vertices)} vertices")
|
||||
|
||||
# --- Fit clothing to body ---
|
||||
method = fit_clothing_to_body(clothing_obj, body_surface)
|
||||
|
||||
# --- Export fitted clothing ---
|
||||
# Delete body surface first so only clothing exports
|
||||
bpy.data.objects.remove(body_surface, do_unlink=True)
|
||||
|
||||
# Re-get clothing obj (still active after modifier apply)
|
||||
clothing_obj = bpy.context.active_object
|
||||
print(f" Exporting to {os.path.basename(output_path)}")
|
||||
export_glb(clothing_obj, output_path)
|
||||
|
||||
return {"body_type": body_type, "status": "ok", "method": method}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python "
|
||||
"tooling/blender_surface_deform_batch.py -- "
|
||||
"<reference_glb> <bodies_dir> <output_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 3:
|
||||
print("ERROR: Provide <reference_glb>, <bodies_dir>, and <output_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
reference_glb = args[0]
|
||||
bodies_dir = args[1]
|
||||
output_dir = args[2]
|
||||
|
||||
# Preflight checks
|
||||
if not os.path.isfile(reference_glb):
|
||||
print(f"ERROR: Reference GLB not found: {reference_glb}")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isdir(bodies_dir):
|
||||
print(f"ERROR: Bodies directory not found: {bodies_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Validate that body type directories are present
|
||||
missing_types = [
|
||||
bt for bt in BODY_TYPES
|
||||
if bt != REFERENCE_BODY and not os.path.isdir(os.path.join(bodies_dir, bt))
|
||||
]
|
||||
if missing_types:
|
||||
print(f"\nWARNING: Missing body type directories (will skip): "
|
||||
f"{', '.join(missing_types)}")
|
||||
|
||||
print("\nSurface Deform Batch Pipeline")
|
||||
print(f" Reference: {reference_glb}")
|
||||
print(f" Bodies: {bodies_dir}")
|
||||
print(f" Output: {output_dir}")
|
||||
print(f" Types: {len(BODY_TYPES)}")
|
||||
|
||||
results = []
|
||||
for body_type in BODY_TYPES:
|
||||
if body_type != REFERENCE_BODY and \
|
||||
not os.path.isdir(os.path.join(bodies_dir, body_type)):
|
||||
results.append({"body_type": body_type, "status": "skipped",
|
||||
"error": "body type directory missing"})
|
||||
continue
|
||||
|
||||
result = process_body_type(body_type, reference_glb, bodies_dir, output_dir)
|
||||
results.append(result)
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
print("=== Surface Deform batch complete ===")
|
||||
ok = [r for r in results if r["status"] == "ok"]
|
||||
errors = [r for r in results if r["status"] == "error"]
|
||||
skipped = [r for r in results if r["status"] == "skipped"]
|
||||
sd_count = len([r for r in ok if r.get("method") == "surface_deform"])
|
||||
sw_count = len([r for r in ok if r.get("method") == "shrinkwrap"])
|
||||
copy_count = len([r for r in ok if r.get("method") == "copy"])
|
||||
|
||||
for r in results:
|
||||
status = r["status"].upper()
|
||||
method = r.get("method", "")
|
||||
error = r.get("error", "")
|
||||
if method:
|
||||
print(f" {r['body_type']:15s} {status:8s} [{method}]")
|
||||
elif error:
|
||||
print(f" {r['body_type']:15s} {status:8s} {error}")
|
||||
else:
|
||||
print(f" {r['body_type']:15s} {status:8s}")
|
||||
|
||||
print(f"\n OK: {len(ok)} "
|
||||
f"(surface_deform={sd_count}, shrinkwrap={sw_count}, copy={copy_count})")
|
||||
if errors:
|
||||
print(f" ERRORS: {len(errors)}")
|
||||
for r in errors:
|
||||
print(f" {r['body_type']}: {r.get('error', '?')}")
|
||||
if skipped:
|
||||
print(f" SKIPPED: {len(skipped)}")
|
||||
if sw_count > 0:
|
||||
print(f"\n WARNING: {sw_count} body type(s) used Shrinkwrap fallback.")
|
||||
print(" Visually verify these variants at gameplay zoom — Shrinkwrap")
|
||||
print(" may produce pinching at extremities on extreme body types.")
|
||||
|
||||
# Write pipeline_log.json for provenance tracking
|
||||
log_path = os.path.join(output_dir, "pipeline_log.json")
|
||||
log_data = {
|
||||
"reference": os.path.basename(reference_glb),
|
||||
"bodies_dir": bodies_dir,
|
||||
"variants": {
|
||||
r["body_type"]: {
|
||||
"status": r["status"],
|
||||
"method": r.get("method", None),
|
||||
"error": r.get("error", None),
|
||||
}
|
||||
for r in results
|
||||
},
|
||||
}
|
||||
with open(log_path, 'w') as f:
|
||||
json.dump(log_data, f, indent=2)
|
||||
print(f"\n Pipeline log written to {log_path}")
|
||||
|
||||
if errors:
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user