Files
settled-reach/tooling/scripts/blender/blender_author_offset_shell.py
T
jpmschweitzerandClaude Opus 5.5 c597ec9131 docs(tooling): T-1253 — sweep the live references to retired tool paths
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>
2026-09-23 20:13:58 +02:00

886 lines
36 KiB
Python

"""
blender_author_offset_shell.py (T-1089, route (c) offset-shell authoring)
Derive a garment SHELL from our own body segment meshes — the "create-stuff-
yourself" authoring pipeline that owes nothing to any vendor pack. Because the
shell IS our body topology, bone weights are inherited by construction (no
Surface-Deform, no Data-Transfer, no re-rig): every vertex keeps the 65-bone
vertex groups it had as skin.
Pipeline (t-shirt reference on average_m):
1. Import the body segments the garment covers (torso + torso_upper + upper
arms), keep only the skinned body meshes (Icosphere debris filtered out).
2. Join into one mesh under a single armature; merge vertex groups by name.
3. Bone-plane SLEEVE cut — trim the upper-arm tube to short-sleeve length via
a coordinate threshold derived from the upperarm bone axis (robust; no
boundary-loop classification, which the segment tool warns is fragile).
Neckline + hem come free as the natural segment boundaries.
4. Offset the surface outward along vertex normals (~12 mm standoff from skin).
5. Solidify (use_rim=True) — gives the cloth real thickness and caps the cut
rims (sleeve openings) into hems.
6. Assign ONE flat modern-fabric material (drops all skin textures). Style pin:
neutral heather tone, no trim, no fantasy anything.
7. Bake a UV0-aligned RGBA REGION MASK per-face: collar band -> R, main body
-> G, sleeve trim -> B (channel-routed 4-tint shader input, G3).
8. Author a 2nd UV channel (TEXCOORD_1) projecting the front chest into [0,1]
for the logo decal (G4); everything else parks outside the box.
9. Export the reference GLB (export_skins=True) + write reference_mask.png and
base_albedo.png sidecars.
Two modes (T-1089):
SINGLE-REFERENCE (default) — author on one body (average_m); G1
(blender_batch_fit_skinned.py) SD-fits it to the other body types. Right for
derived/hand-authored garments that share one UV layout + one mask.
reach blender run blender_author_offset_shell \
<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.
reach blender run blender_author_offset_shell \
<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])
me.uv_layers.new(name="logo_uv") # created for its side effect: the logo UV layer
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()