A script scanned every tracked doc, rule, skill, agent, hook and source file for tooling/ paths that no longer exist, skipping historical records (sprints, discussions, workshops, governance, generated wiki pages). It found 62. The ones that tell a reader what to RUN now name the reach verb: - The atlas skill still sent agents to tooling/atlas, atlas-verify, atlas-update-field and atlas-commit-and-sync — about forty lines, all retired in T-1285. They now name the `reach atlas` verbs, and the skill records that commit-and-sync STAGES by default (--commit to commit) and takes --corridor as an option. - The clerk agent named tooling/clerk-review (now `reach dev clerk`). The Si and clerk briefings sent those agents to the retired tooling/db/decision and sqlite-query CLIs and to decisions/*.md paths that moved to governance/ in the pql migration. They now name pql. - The ticket-cli rule documented `pql decisions read`, which does not exist; `show` already includes the body. - The culture authoring guide and the RON sources name `reach validate ron`, with the same arguments as before. - The 41 Blender payloads' usage lines ran the retired tooling/blender wrapper, and the docstrings still cited pre-carve-out paths. They now read `reach blender run <payload>`. - Doc comments in server/, client/, wiki TOMLs and the domain modules. What is left is deliberate: "Formerly …" provenance, dated plans and findings docs, the retired-pipeline doc, and a build-artefact path. project.yaml 0.4.14 (mirrored to the client). Comment-only, but four touched files are in the canvas-version registry (trait_catalog_reader.rs, since T-1289, canvas_sources.py itself, and two client files). The gate is path-based and has no override. The previous push was rejected on exactly this. Three of the edits are stamped ledger sources, so systems.db is regenerated and the stamp is fresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
676 lines
28 KiB
Python
676 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):
|
|
reach blender run blender_author_slides \
|
|
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()
|