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>
719 lines
29 KiB
Python
719 lines
29 KiB
Python
"""
|
|
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()
|