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>
886 lines
36 KiB
Python
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()
|