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>
677 lines
28 KiB
Python
677 lines
28 KiB
Python
"""
|
|
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()
|