Files
animation/clothing/garment_pipeline.py
T

1012 lines
43 KiB
Python
Raw Normal View History

# Garment pipeline: clothing mesh (any source) -> game-ready outfit-part GLBs
# on the shared 65-bone Quaternius skeleton. See README.md for design rationale
# (GLM-reviewed: fit-before-decimate, skirt proxy + cone weights, inner-shell
# clearance, layer stacking).
#
# Staged; every stage loads the previous checkpoint .blend from work/ and saves
# its own, so any stage can be re-run alone while iterating:
#
# blender --background --python garment_pipeline.py -- \
# --config configs/dress.json --stage prepare|fit|reduce|bake|skin|export
#
# Naming inside the .blend (stable contract between stages):
# GARM_<Part> garment part mesh (high-poly until `reduce`, low-poly after)
# HI_<Part> hidden high-poly copy kept for baking (created by `reduce`)
# BODY the target body mesh (from the game GLB, with vertex groups)
# BODY_SHELL inflated clearance copy of BODY (fit target)
# RIG the Quaternius armature imported with BODY
# CONE_<Part> skirt weight-transfer proxy (created by `skin`, kept for QA)
import bpy, bmesh, json, math, os, sys, argparse, random
from mathutils import Vector, Matrix
random.seed(7)
ARGS = None
CFG = None
HERE = os.path.dirname(os.path.abspath(__file__))
# ── plumbing ─────────────────────────────────────────────────────────────────
def log(msg):
print(f"[cloth] {msg}", flush=True)
def work_path(name):
d = os.path.join(HERE, "work", CFG["name"])
os.makedirs(d, exist_ok=True)
return os.path.join(d, name)
def save_checkpoint(stage):
p = work_path(f"{stage}.blend")
bpy.ops.wm.save_as_mainfile(filepath=p, compress=True)
log(f"checkpoint saved: {p}")
def load_checkpoint(stage):
p = work_path(f"{stage}.blend")
if not os.path.exists(p):
raise SystemExit(f"missing checkpoint {p} — run stage '{stage}' first")
bpy.ops.wm.open_mainfile(filepath=p)
log(f"checkpoint loaded: {p}")
def obj(name):
o = bpy.data.objects.get(name)
if o is None:
raise SystemExit(f"expected object '{name}' not in scene")
return o
def objs_prefixed(prefix):
return [o for o in bpy.data.objects if o.name.startswith(prefix)]
def select_only(objects, active=None):
bpy.ops.object.select_all(action="DESELECT")
for o in objects:
o.select_set(True)
bpy.context.view_layer.objects.active = active or objects[0]
def apply_all_transforms(objects):
select_only(objects)
bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM")
for o in objects:
select_only([o])
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
def apply_modifier(o, mod_name):
select_only([o])
bpy.ops.object.modifier_apply(modifier=mod_name)
def tris_of(o):
o.data.calc_loop_triangles()
return len(o.data.loop_triangles)
def world_verts(o):
return [o.matrix_world @ v.co for v in o.data.vertices]
def zspan(objects):
vs = [v for o in objects for v in world_verts(o)]
zs = [v.z for v in vs]
return min(zs), max(zs)
def qa_render(tag, focus_objects=None, ortho_pad=1.25, color_type="TEXTURE"):
"""Front + 3/4 workbench renders of the current scene into work/.
Defaults to TEXTURE shading so a QA render shows the fabric print, not just
the placeholder colour — census passes MATERIAL to keep its island coding.
"""
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = color_type
scene.render.resolution_x, scene.render.resolution_y = 900, 1200
vs = [v for o in (focus_objects or [o for o in bpy.data.objects if o.type == "MESH" and o.visible_get()])
for v in world_verts(o)]
if not vs:
return
zmin, zmax = min(v.z for v in vs), max(v.z for v in vs)
cz, h = (zmin + zmax) / 2, (zmax - zmin)
for view, loc, rot in (("front", (0, -4, cz), (math.pi / 2, 0, 0)),
("quarter", (2.7, -2.9, cz + 0.15), (math.radians(80), 0, math.radians(42)))):
cam_name = f"_qa_{view}"
cam = bpy.data.objects.get(cam_name)
if cam is None:
cam = bpy.data.objects.new(cam_name, bpy.data.cameras.new(cam_name))
bpy.context.scene.collection.objects.link(cam)
cam.data.type = "ORTHO"
cam.data.ortho_scale = h * ortho_pad
cam.location, cam.rotation_euler = loc, rot
scene.camera = cam
scene.render.filepath = work_path(f"qa_{tag}_{view}.png")
bpy.ops.render.render(write_still=True)
log(f"QA render: {scene.render.filepath}")
def bone_head_z(rig, bone):
return (rig.matrix_world @ rig.data.bones[bone].head_local).z
# ── garment import (shared by census + prepare) ─────────────────────────────
# MD exports hide the real structure: fabric may live on ONE material while
# millions of faces are topstitch threads. So: drop configured stitch/trash
# materials, weld, split by LOOSE PARTS, and identify pieces as "islands"
# (indexed by vertex count, deterministic). Parts are then configured as island
# index lists (see census renders).
def import_garment_islands():
pre = set(bpy.data.objects)
src = CFG["source"]
ext = os.path.splitext(src)[1].lower()
if ext == ".fbx":
bpy.ops.import_scene.fbx(filepath=src)
elif ext in (".glb", ".gltf"):
bpy.ops.import_scene.gltf(filepath=src)
elif ext == ".obj":
bpy.ops.wm.obj_import(filepath=src)
else:
raise SystemExit(f"unsupported garment format {ext}")
new = [o for o in bpy.data.objects if o not in pre]
meshes = [o for o in new if o.type == "MESH"]
for o in [o for o in new if o.type != "MESH"]:
bpy.data.objects.remove(o, do_unlink=True)
for g in meshes:
for m in list(g.modifiers):
g.modifiers.remove(m)
g.vertex_groups.clear()
apply_all_transforms(meshes)
drop = set(CFG.get("drop_materials", []))
for g in meshes:
if not g.data.materials:
continue
drop_idx = {i for i, m in enumerate(g.data.materials) if m and m.name in drop}
if not drop_idx:
continue
bm = bmesh.new()
bm.from_mesh(g.data)
doomed = [f for f in bm.faces if f.material_index in drop_idx]
bmesh.ops.delete(bm, geom=doomed, context="FACES")
bm.to_mesh(g.data)
bm.free()
meshes = [g for g in meshes if len(g.data.vertices) > 0]
log(f"after drop_materials: {sum(len(g.data.vertices) for g in meshes)} verts")
# weld panels, then split to loose islands
select_only(meshes)
bpy.ops.object.join()
fabric = bpy.context.view_layer.objects.active
select_only([fabric])
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.remove_doubles(threshold=CFG.get("weld_threshold", 0.0006))
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.mesh.separate(type="LOOSE")
bpy.ops.object.mode_set(mode="OBJECT")
islands = [o for o in bpy.data.objects if o.type == "MESH" and o.name not in ("BODY", "BODY_SHELL")]
min_verts = CFG.get("min_island_verts", 40)
kept = []
for o in islands:
if len(o.data.vertices) < min_verts:
bpy.data.objects.remove(o, do_unlink=True)
else:
kept.append(o)
# deterministic index: vertex count desc, then centroid x/z
def key(o):
vs = world_verts(o)
c = sum(vs, Vector()) / len(vs)
return (-len(o.data.vertices), round(c.x, 4), round(c.z, 4))
kept.sort(key=key)
for i, o in enumerate(kept):
o.name = f"ISL_{i:03d}"
log(f"islands kept: {len(kept)} (dropped tiny <{min_verts} verts)")
return kept
CENSUS_COLORS = [
(0.90, 0.10, 0.10), (0.10, 0.55, 0.95), (0.10, 0.80, 0.20), (0.95, 0.75, 0.10),
(0.75, 0.15, 0.85), (0.05, 0.85, 0.80), (0.95, 0.45, 0.05), (0.55, 0.35, 0.15),
(0.95, 0.55, 0.75), (0.45, 0.95, 0.45), (0.25, 0.25, 0.95), (0.80, 0.80, 0.30),
(0.50, 0.05, 0.30), (0.05, 0.45, 0.35), (0.65, 0.65, 0.95), (0.95, 0.85, 0.65),
(0.30, 0.10, 0.60), (0.10, 0.30, 0.10), (0.60, 0.30, 0.30), (0.30, 0.60, 0.60),
]
def stage_census():
bpy.ops.wm.read_factory_settings(use_empty=True)
islands = import_garment_islands()
report = []
for i, o in enumerate(islands):
vs = world_verts(o)
zs = [v.z for v in vs]; xs = [v.x for v in vs]; ys = [v.y for v in vs]
c = sum(vs, Vector()) / len(vs)
color = CENSUS_COLORS[i % len(CENSUS_COLORS)] if i < len(CENSUS_COLORS) else (0.5, 0.5, 0.5)
mat = bpy.data.materials.new(f"census_{i:03d}")
mat.diffuse_color = (*color, 1.0)
o.data.materials.clear()
o.data.materials.append(mat)
report.append({"island": i, "verts": len(o.data.vertices), "tris": tris_of(o),
"z": [round(min(zs), 3), round(max(zs), 3)],
"x": [round(min(xs), 3), round(max(xs), 3)],
"centroid": [round(c.x, 3), round(c.y, 3), round(c.z, 3)],
"color": [round(v, 2) for v in color]})
log(f"ISL_{i:03d} verts={len(o.data.vertices):7d} z=[{min(zs):.3f},{max(zs):.3f}] "
f"x=[{min(xs):+.3f},{max(xs):+.3f}] color={tuple(round(v,2) for v in color)}")
with open(work_path("census.json"), "w", encoding="utf-8") as f:
json.dump(report, f, indent=1)
qa_render("00_census", focus_objects=islands, color_type="MATERIAL")
save_checkpoint("00_census")
# ── fabric texture ───────────────────────────────────────────────────────────
# Marvelous Designer bakes no texture into the export, but it DOES export the UVs
# it draped the fabric with, so re-pointing the same PNG at those UVs restores the
# print exactly where it was authored — no offset needed. The UVs are centred on
# zero and measured in texture repeats (a skirt whose v spans -0.5..0.5 wears the
# tile exactly once); negative coordinates wrap, so they sample correctly as-is.
#
# Verify a scale/offset change in the GAME, not a Blender QA render: Workbench
# shading ignores shader Mapping nodes, so a wrong transform renders correct here
# and wrong in Godot. That is why the transform is baked into the UV data below.
def apply_fabric_texture(mat, part_cfg, mesh):
tex = part_cfg.get("texture")
if not tex:
return
path = tex if os.path.isabs(tex) else os.path.join(HERE, tex)
if not os.path.exists(path):
log(f"WARNING: texture not found, keeping flat colour: {path}")
return
if not mesh.uv_layers:
log(f"WARNING: no UV map on {mesh.name}, keeping flat colour")
return
scale = part_cfg.get("texture_scale", [1.0, 1.0])
offset = part_cfg.get("texture_offset", [0.0, 0.0])
if scale != [1.0, 1.0] or offset != [0.0, 0.0]:
for loop_uv in mesh.uv_layers[0].data:
loop_uv.uv[0] = loop_uv.uv[0] * scale[0] + offset[0]
loop_uv.uv[1] = loop_uv.uv[1] * scale[1] + offset[1]
nodes, links = mat.node_tree.nodes, mat.node_tree.links
img_node = nodes.new("ShaderNodeTexImage")
img_node.image = bpy.data.images.load(path, check_existing=True)
bsdf = nodes.get("Principled BSDF")
if bsdf:
links.new(img_node.outputs["Color"], bsdf.inputs["Base Color"])
log(f"texture {os.path.basename(path)}{mesh.name} "
f"(uv={mesh.uv_layers[0].name}, scale={scale}, offset={offset})")
# ── stage: prepare ───────────────────────────────────────────────────────────
# Group census islands into configured parts (each part lists island indices),
# import body+rig, align garment onto body.
def stage_prepare():
bpy.ops.wm.read_factory_settings(use_empty=True)
# body + rig first (defines target space)
body_path = CFG["body"]
bpy.ops.import_scene.gltf(filepath=body_path)
rig = next(o for o in bpy.data.objects if o.type == "ARMATURE")
rig.name = "RIG"
body = max((o for o in bpy.data.objects if o.type == "MESH"),
key=lambda o: len(o.data.vertices))
# drop face-part extras (eyes etc.) that ride along in the body GLB
for o in [o for o in bpy.data.objects if o.type == "MESH" and o is not body]:
bpy.data.objects.remove(o, do_unlink=True)
body.name = "BODY"
apply_all_transforms([rig, body])
log(f"body: {len(body.data.vertices)} verts, groups={len(body.vertex_groups)}, "
f"bones={len(rig.data.bones)}")
# garment islands (same deterministic indexing the census printed)
islands = import_garment_islands()
by_idx = {int(o.name.split("_")[1]): o for o in islands}
claimed = set()
for part_name, part_cfg in CFG["parts"].items():
ids = part_cfg.get("islands", [])
members = [by_idx[i] for i in ids if i in by_idx]
if not members:
log(f"WARNING: part {part_name} matched no islands ({ids})")
continue
claimed.update(ids)
select_only(members, active=members[0])
bpy.ops.object.join()
part = bpy.context.view_layer.objects.active
part.name = f"GARM_{part_name}"
mat = bpy.data.materials.new(f"{part_name}_mat")
col = part_cfg.get("color", [0.7, 0.7, 0.7])
mat.diffuse_color = (*col, 1.0)
if mat.use_nodes is False:
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = (*col, 1.0)
bsdf.inputs["Roughness"].default_value = 0.85
apply_fabric_texture(mat, part_cfg, part.data)
part.data.materials.clear()
part.data.materials.append(mat)
log(f"part {part.name}: islands={ids} verts={len(part.data.vertices)} tris={tris_of(part)}")
for i, o in list(by_idx.items()):
if i not in claimed and o.name.startswith("ISL_"):
bpy.data.objects.remove(o, do_unlink=True)
# bodyshell parts: fitted clothing built FROM the body (Islander technique):
# copy the body, keep the configured z-band + torso-dominant vertices, push
# outward along normals. Fit and weights are correct by construction.
for part_name, part_cfg in CFG["parts"].items():
if part_cfg.get("type") != "bodyshell":
continue
shellp = body.copy()
shellp.data = body.data.copy()
shellp.name = f"GARM_{part_name}"
bpy.context.scene.collection.objects.link(shellp)
z0, z1 = part_cfg["z0"], part_cfg["z1"]
allowed = set(part_cfg.get("bones", ["spine_01", "spine_02", "spine_03", "pelvis"]))
gnames = {i: g.name for i, g in enumerate(shellp.vertex_groups)}
keep = set()
for v in shellp.data.vertices:
z = (shellp.matrix_world @ v.co).z
if not (z0 <= z <= z1):
continue
best, bw = None, 0.0
for g in v.groups:
if g.weight > bw:
best, bw = gnames.get(g.group), g.weight
if best in allowed:
keep.add(v.index)
bm = bmesh.new()
bm.from_mesh(shellp.data)
bm.verts.ensure_lookup_table()
doomed = [v for v in bm.verts if v.index not in keep]
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
bm.to_mesh(shellp.data)
bm.free()
d = shellp.modifiers.new("Offset", "DISPLACE")
d.strength = part_cfg.get("offset_mm", 6) / 1000.0
d.mid_level = 0.0
apply_modifier(shellp, d.name)
mat = bpy.data.materials.new(f"{part_name}_mat")
col = part_cfg.get("color", [0.7, 0.7, 0.7])
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = (*col, 1.0)
bsdf.inputs["Roughness"].default_value = 0.85
mat.diffuse_color = (*col, 1.0)
shellp.data.materials.clear()
shellp.data.materials.append(mat)
log(f"bodyshell {shellp.name}: verts={len(shellp.data.vertices)} tris={tris_of(shellp)}")
parts = [o for o in objs_prefixed("GARM_")
if CFG["parts"][o.name[5:]].get("type") != "bodyshell"]
if not parts:
raise SystemExit("no parts matched any islands — check config vs census.json")
# sleeve pose-warp FIRST, in garment-local space (unambiguous: sleeves are
# the only geometry beyond x_beyond). Rigid rotation around the garment's
# own shoulder point, bringing each cuff to horizontal (T-pose).
for part_name, part_cfg in CFG["parts"].items():
wcfg = part_cfg.get("sleeve_warp")
if not wcfg:
continue
p = bpy.data.objects.get(f"GARM_{part_name}")
if p is None:
continue
x_beyond = wcfg.get("x_beyond", 0.28)
sx, sz = wcfg.get("shoulder", [0.19, 1.43])
for sign in (1.0, -1.0):
pivot = Vector((sign * sx, 0.0, sz))
sleeve = [v for v in p.data.vertices if sign * v.co.x > x_beyond]
if len(sleeve) < 10:
log(f"sleeve_warp {part_name} side {sign:+.0f}: none found")
continue
tip = max(sleeve, key=lambda v: sign * v.co.x)
cur = math.atan2(tip.co.z - pivot.z, sign * (tip.co.x - pivot.x))
rot = Matrix.Rotation(-sign * cur, 4, "Y")
M = Matrix.Translation(pivot) @ rot @ Matrix.Translation(-pivot)
for v in sleeve:
v.co = M @ v.co
log(f"sleeve_warp {part_name} side {sign:+.0f}: {math.degrees(cur):+.1f} deg, "
f"{len(sleeve)} verts")
a = CFG.get("align", {})
sxy = a.get("scale_xy", a.get("scale", 1.0)) or 1.0
sz = a.get("scale_z", a.get("scale", 1.0)) or 1.0
M = Matrix.Diagonal(Vector((sxy, sxy, sz))).to_4x4()
for p in parts:
p.data.transform(M)
gz0, gz1 = zspan(parts)
b_top = bone_head_z(obj("RIG"), a.get("top_bone", "neck_01"))
dz = (b_top - gz1) + a.get("z_nudge", 0.0)
dxy = Vector(a.get("xy_nudge", (0, 0)))
T = Matrix.Translation(Vector((dxy.x, dxy.y, dz)))
for p in parts:
p.data.transform(T)
log(f"align: scale_xy={sxy:.3f} scale_z={sz:.3f} dz={dz:+.4f}")
# torso boost: heroic bodies are wider than MD mannequins — radially inflate
# the above-waist region about the body axis so bodice/collar fabric starts
# OUTSIDE the body (direction-preserving; the OUTSIDE shrinkwrap then only
# catches stragglers instead of shredding buried cloth to random sides).
tb = CFG.get("torso_boost")
if tb:
z0, z1, boost = tb.get("z0", 1.00), tb.get("z1", 1.08), tb.get("xy", 1.18)
for p in parts:
for v in p.data.vertices:
z = v.co.z
if z <= z0:
continue
t = 1.0 if z >= z1 else (z - z0) / max(1e-9, z1 - z0)
f = 1.0 + (boost - 1.0) * t
v.co.x *= f
v.co.y *= f
log(f"torso_boost: xy x{boost} above z={z0}..{z1}")
# post-align cut: drop configured fabric above a body-space height (v1 uses
# this to remove the slim MD bodice+sleeves replaced by the bodyshell part)
for part_name, part_cfg in CFG["parts"].items():
cut = part_cfg.get("cut_above_z")
p = bpy.data.objects.get(f"GARM_{part_name}")
if cut is None or p is None or part_cfg.get("type") == "bodyshell":
continue
bm = bmesh.new()
bm.from_mesh(p.data)
ox = part_cfg.get("cut_outboard_x")
oz = part_cfg.get("cut_outboard_z_above", 0.0)
def out(v):
w = p.matrix_world @ v.co
return w.z > cut or (ox is not None and w.z > oz and abs(w.x) > ox)
doomed = [v for v in bm.verts if out(v)]
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
bm.to_mesh(p.data)
bm.free()
log(f"cut {p.name} above z={cut}: now {len(p.data.vertices)} verts")
# prune disconnected scraps left by the cuts (e.g. sleeve remnants):
# separate loose, keep the biggest piece + anything near the center axis
px = part_cfg.get("prune_scraps_x")
if px is not None:
pname = p.name
select_only([p])
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.separate(type="LOOSE")
bpy.ops.object.mode_set(mode="OBJECT")
pieces = [o for o in bpy.data.objects if o.name.startswith(pname)]
main = max(pieces, key=lambda o: len(o.data.vertices))
keepers = [main]
for o in pieces:
if o is main:
continue
vs = world_verts(o)
c = sum(vs, Vector()) / len(vs)
if abs(c.x) <= px:
keepers.append(o)
else:
bpy.data.objects.remove(o, do_unlink=True)
select_only(keepers, active=main)
bpy.ops.object.join()
joined = bpy.context.view_layer.objects.active
joined.name = pname
log(f"prune {joined.name}: kept {len(keepers)} pieces, "
f"{len(joined.data.vertices)} verts")
qa_render("10_prepare")
save_checkpoint("10_prepare")
# ── stage: fit ───────────────────────────────────────────────────────────────
# Build the inflated BODY_SHELL, give each part a Z-gradient influence mask, and
# shrinkwrap high-poly parts in configured layer order (later layers can target
# earlier ones).
def make_shell():
body = obj("BODY")
shell = body.copy()
shell.data = body.data.copy()
shell.name = "BODY_SHELL"
bpy.context.scene.collection.objects.link(shell)
d = shell.modifiers.new("Inflate", "DISPLACE")
d.strength = CFG.get("shell_mm", 4) / 1000.0
d.mid_level = 0.0
apply_modifier(shell, d.name)
shell.hide_render = True
return shell
def zgradient_group(o, name, z_full, z_zero):
"""Vertex group: weight 1 at z_full … 0 at z_zero, linear between.
Works in either direction: z_full above z_zero grades upward (waistband
masks), z_full below z_zero grades downward (hem clearance masks).
"""
vg = o.vertex_groups.get(name) or o.vertex_groups.new(name=name)
mw = o.matrix_world
for v in o.data.vertices:
z = (mw @ v.co).z
t = (z - z_zero) / ((z_full - z_zero) or 1e-9)
vg.add([v.index], min(1.0, max(0.0, t)), "REPLACE")
return vg
def stage_fit():
load_checkpoint("10_prepare")
rig = obj("RIG")
shell = make_shell()
hip_z = bone_head_z(rig, "pelvis")
for part_name, part_cfg in CFG["parts"].items():
p = bpy.data.objects.get(f"GARM_{part_name}")
if p is None:
continue
if part_cfg.get("type") == "bodyshell":
log(f"fit GARM_{part_name}: skipped (bodyshell)")
continue
fit = part_cfg.get("fit", {})
target_name = fit.get("target", "BODY_SHELL")
target = obj(target_name if target_name != "BODY_SHELL" else "BODY_SHELL")
mode = fit.get("mask", "full") # full | above_hip | waistband | none
if mode == "none":
log(f"fit {p.name}: skipped (mask none)")
continue
if mode == "full":
vg = None
elif mode == "above_hip":
vg = zgradient_group(p, "fit_mask", hip_z + 0.02, hip_z - 0.06)
elif mode == "waistband":
vg = zgradient_group(p, "fit_mask", hip_z + 0.10, hip_z - 0.02)
sw = p.modifiers.new("Fit", "SHRINKWRAP")
sw.target = target
sw.wrap_method = "NEAREST_SURFACEPOINT"
sw.wrap_mode = fit.get("wrap_mode", "OUTSIDE")
sw.offset = fit.get("offset_mm", 2) / 1000.0
if vg is not None:
sw.vertex_group = vg.name
apply_modifier(p, sw.name)
log(f"fit {p.name}: target={target.name} mask={mode} offset={sw.offset if hasattr(sw,'offset') else '?'}")
# Graded hem clearance: the uniform shell offset is right at the waist but
# far too tight where the legs travel — swing clips straight through a hem
# fitted 4mm off the bind pose. A second OUTSIDE shrinkwrap, masked 0 at the
# hip → 1 at the hem, pushes only too-close fabric out to hem_mm beyond the
# shell; fabric already hanging clear never moves.
hem_mm = fit.get("hem_mm", 0)
if hem_mm:
z_top = fit.get("hem_z_top", hip_z)
z_bot = min(v.z for v in world_verts(p))
hem_vg = zgradient_group(p, "hem_mask", z_bot, z_top)
hw = p.modifiers.new("HemClear", "SHRINKWRAP")
hw.target = target
hw.wrap_method = "NEAREST_SURFACEPOINT"
hw.wrap_mode = "OUTSIDE"
hw.offset = hem_mm / 1000.0
hw.vertex_group = hem_vg.name
apply_modifier(p, hw.name)
log(f"hem clearance {p.name}: +{hem_mm}mm graded z={z_top:.3f}{z_bot:.3f}")
qa_render("20_fit")
save_checkpoint("20_fit")
# ── stage: reduce ────────────────────────────────────────────────────────────
# Keep a hidden HI_<part> copy for baking. Skirt-style parts are replaced by a
# generated cylinder proxy shrinkwrapped to the high-poly; others get planar
# dissolve then collapse decimate down to budget.
def make_skirt_proxy(part, proxy_cfg):
seg = proxy_cfg.get("segments", 30)
rings = proxy_cfg.get("rings", 6)
vs = world_verts(part)
zs = sorted(v.z for v in vs)
z0, z1 = zs[int(0.01 * len(zs))], zs[int(0.99 * len(zs))]
cx = sum(v.x for v in vs) / len(vs)
cy = sum(v.y for v in vs) / len(vs)
def ring_radius(z):
band = [v for v in vs if abs(v.z - z) < (z1 - z0) / (rings * 1.5)]
if not band:
return 0.2
ds = sorted(math.hypot(v.x - cx, v.y - cy) for v in band)
return ds[int(0.65 * len(ds))]
me = bpy.data.meshes.new(f"{part.name}_proxy")
bm = bmesh.new()
ring_verts = []
for r in range(rings + 1):
z = z1 - (z1 - z0) * (r / rings)
rad = ring_radius(z)
ring = [bm.verts.new((cx + rad * math.cos(2 * math.pi * s / seg),
cy + rad * math.sin(2 * math.pi * s / seg), z))
for s in range(seg)]
ring_verts.append(ring)
for r in range(rings):
for s in range(seg):
a, b = ring_verts[r][s], ring_verts[r][(s + 1) % seg]
c, d = ring_verts[r + 1][(s + 1) % seg], ring_verts[r + 1][s]
bm.faces.new((a, b, c, d))
bm.to_mesh(me)
bm.free()
proxy = bpy.data.objects.new(f"{part.name}_proxytmp", me)
bpy.context.scene.collection.objects.link(proxy)
sw = proxy.modifiers.new("WrapToSkirt", "SHRINKWRAP")
sw.target = part
sw.wrap_method = "NEAREST_SURFACEPOINT"
apply_modifier(proxy, sw.name)
select_only([proxy])
bpy.ops.object.shade_smooth()
# carry the part's (single) material
if part.data.materials:
proxy.data.materials.append(part.data.materials[0])
return proxy
def stage_reduce():
load_checkpoint("20_fit")
for part_name, part_cfg in CFG["parts"].items():
p = bpy.data.objects.get(f"GARM_{part_name}")
if p is None:
continue
# keep high-poly for baking
hi = p.copy()
hi.data = p.data.copy()
hi.name = f"HI_{part_name}"
bpy.context.scene.collection.objects.link(hi)
hi.hide_render = hi.hide_viewport = True
wb = part_cfg.get("weld_band") # [z0, z1, dist]: fuse stacked layers
if wb:
z0, z1, dist = wb
select_only([p])
bm = bmesh.new()
bm.from_mesh(p.data)
mw = p.matrix_world
band = [v for v in bm.verts if z0 <= (mw @ v.co).z <= z1]
bmesh.ops.remove_doubles(bm, verts=band, dist=dist)
bm.to_mesh(p.data)
bm.free()
log(f"weld_band {p.name}: z=[{z0},{z1}] dist={dist} -> {len(p.data.vertices)} verts")
budget = part_cfg.get("tris", 1500)
if part_cfg.get("proxy") == "cylinder":
proxy = make_skirt_proxy(p, part_cfg.get("proxy_cfg", {}))
name = p.name
bpy.data.objects.remove(p, do_unlink=True)
proxy.name = name
p = proxy
else:
# planar dissolve first (MD panels are near-planar), then collapse
dm = p.modifiers.new("Planar", "DECIMATE")
dm.decimate_type = "DISSOLVE"
dm.angle_limit = math.radians(part_cfg.get("planar_deg", 5))
apply_modifier(p, dm.name)
cur = tris_of(p)
if cur > budget:
dm = p.modifiers.new("Collapse", "DECIMATE")
dm.decimate_type = "COLLAPSE"
dm.ratio = budget / cur
apply_modifier(p, dm.name)
select_only([p])
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")
log(f"reduce {p.name}: tris={tris_of(p)} (budget {budget})")
qa_render("30_reduce", focus_objects=objs_prefixed("GARM_"))
save_checkpoint("30_reduce")
# ── stage: bake ──────────────────────────────────────────────────────────────
# Normal + AO from HI_<part> onto GARM_<part> via a second UV map. Slow (Cycles).
def stage_bake():
load_checkpoint("30_reduce")
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 32
scene.cycles.seed = 7
for part_name, part_cfg in CFG["parts"].items():
lo = bpy.data.objects.get(f"GARM_{part_name}")
hi = bpy.data.objects.get(f"HI_{part_name}")
if lo is None or hi is None:
continue
hi.hide_viewport = hi.hide_render = False
size = part_cfg.get("bake_size", 512)
# bake UVs
select_only([lo])
uv = lo.data.uv_layers.new(name="bake_uv")
lo.data.uv_layers.active = uv
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project(angle_limit=math.radians(66))
bpy.ops.object.mode_set(mode="OBJECT")
# target images + nodes on the part's material
mat = lo.data.materials[0] if lo.data.materials else None
if mat is None:
mat = bpy.data.materials.new(f"{part_name}_mat")
lo.data.materials.append(mat)
mat.use_nodes = True
nt = mat.node_tree
imgs = {}
for kind in ("NORMAL", "AO"):
img = bpy.data.images.new(f"{part_name}_{kind.lower()}", size, size,
alpha=False, float_buffer=(kind == "NORMAL"))
node = nt.nodes.new("ShaderNodeTexImage")
node.image = img
nt.nodes.active = node
select_only([hi, lo], active=lo)
bpy.ops.object.bake(type=kind, use_selected_to_active=True,
cage_extrusion=0.004,
use_clear=True)
out = work_path(f"{part_name}_{kind.lower()}.png")
img.filepath_raw = out
img.file_format = "PNG"
img.save()
imgs[kind] = img
log(f"baked {kind} for {part_name} -> {out}")
# wire into the material (normal map + AO multiply into base color)
bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED")
nrm_tex = nt.nodes.new("ShaderNodeTexImage")
nrm_tex.image = imgs["NORMAL"]
nrm_tex.image.colorspace_settings.name = "Non-Color"
nmap = nt.nodes.new("ShaderNodeNormalMap")
nmap.uv_map = "bake_uv"
nt.links.new(nmap.inputs["Color"], nrm_tex.outputs["Color"])
nt.links.new(bsdf.inputs["Normal"], nmap.outputs["Normal"])
hi.hide_viewport = hi.hide_render = True
save_checkpoint("40_bake")
# ── stage: skin ──────────────────────────────────────────────────────────────
# Weight transfer. Snug parts: Data Transfer (nearest face interpolated) from
# BODY. Skirt parts: cone proxy — data-transferred at the waist, pelvis-locked
# at the hem, then transferred onto the skirt.
def data_transfer_weights(dst, src):
dst.vertex_groups.clear()
for vg in src.vertex_groups:
dst.vertex_groups.new(name=vg.name)
dt = dst.modifiers.new("Weights", "DATA_TRANSFER")
dt.object = src
dt.use_vert_data = True
dt.data_types_verts = {"VGROUP_WEIGHTS"}
dt.vert_mapping = "POLYINTERP_NEAREST"
dt.layers_vgroup_select_src = "ALL"
dt.layers_vgroup_select_dst = "NAME"
apply_modifier(dst, dt.name)
def smooth_and_normalize(o, iterations=3):
select_only([o])
bpy.ops.object.mode_set(mode="WEIGHT_PAINT")
try:
bpy.ops.object.vertex_group_smooth(group_select_mode="ALL",
factor=0.5, repeat=iterations)
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
finally:
bpy.ops.object.mode_set(mode="OBJECT")
def strip_bone_groups(o, prefixes):
"""Remove vertex groups whose bone should never drive this garment (e.g.
upperarm_* on a sleeveless top — nearest-face transfer grabs the arm surface
at the armholes and arm swings then wrench the garment). The stripped verts
are healed by the smooth+normalize pass that follows in stage_skin."""
doomed = [vg for vg in o.vertex_groups
if any(vg.name.startswith(pre) for pre in prefixes)]
for vg in doomed:
o.vertex_groups.remove(vg)
return [vg.name for vg in doomed] if doomed else []
def pelvis_gradient(o, hip_z, hem_z, max_t=1.0):
"""Below the hip, blend all weights toward pelvis-only as z drops to hem.
max_t < 1 leaves (1 - max_t) of the transferred leg weights alive at the
hem so the cloth partially follows a raised thigh instead of letting it
punch straight through a fully pelvis-locked skirt."""
idx_pelvis = o.vertex_groups.find("pelvis")
if idx_pelvis < 0:
o.vertex_groups.new(name="pelvis")
idx_pelvis = o.vertex_groups.find("pelvis")
mw = o.matrix_world
for v in o.data.vertices:
z = (mw @ v.co).z
if z >= hip_z:
continue
t = min(max_t, (hip_z - z) / max(1e-9, hip_z - hem_z)) # 0 at hip → max_t at hem
for g in v.groups:
g.weight *= (1.0 - t)
o.vertex_groups[idx_pelvis].add([v.index], 0.0, "ADD")
# set pelvis weight so the total stays 1 after normalize
cur = sum(g.weight for g in v.groups)
o.vertex_groups[idx_pelvis].add([v.index], max(0.0, 1.0 - cur), "ADD")
def skirt_bone_blend(o, rig, hip_z, ramp_m=0.20):
"""Blend transferred body weights toward the skirt_* bone ring below the hip
(bodies built by ariki-game/tools/make_skirt_rig_body.py; the runtime
SpringBoneSimulator3D in ariki-game SkirtSpringRig.cs owns those bones).
Each vertex maps to the nearest TWO skirt bones by azimuth around the pelvis
axis, angularly interpolated — adjacent verts land on near-identical blends,
so there is no left/right seam to tear (the reason pelvis_gradient existed).
Blend factor t ramps 0 at the hip → 1 at ramp_m below it: by mid-thigh the
ring owns the cloth outright, which is what lets a thigh capsule push the
skirt out of a lifted knee's way instead of averaging against leg weights.
"""
mw_rig = rig.matrix_world
pelvis = mw_rig @ rig.data.bones["pelvis"].head_local
ring = [] # (azimuth, top-group idx, tip-group idx or None, z_mid)
for b in rig.data.bones:
if not b.name.startswith("skirt_") or b.name.endswith("_01"):
continue
head = mw_rig @ b.head_local
az = math.atan2(head.y - pelvis.y, head.x - pelvis.x)
if o.vertex_groups.find(b.name) < 0:
o.vertex_groups.new(name=b.name)
tip = rig.data.bones.get(b.name + "_01")
tip_idx, z_mid = None, None
if tip is not None:
if o.vertex_groups.find(tip.name) < 0:
o.vertex_groups.new(name=tip.name)
tip_idx = o.vertex_groups.find(tip.name)
z_mid = (mw_rig @ tip.head_local).z
ring.append((az, o.vertex_groups.find(b.name), tip_idx, z_mid))
if not ring:
raise SystemExit("[skin] weights=skirt_bones but the body rig has no skirt_* "
"bones — point the config's `body` at a *_SkirtRig.glb copy")
ring.sort()
n = len(ring)
skirt_idx = {i for _, top, tip, _ in ring for i in (top, tip) if i is not None}
SEG_BAND = 0.05 # metres of top↔tip blend either side of the segment joint
def strand_weights(entry, z, w):
"""Split strand weight w between its two segments by height."""
_, top, tip, z_mid = entry
if tip is None:
return [(top, w)]
fz = min(1.0, max(0.0, (z_mid + SEG_BAND - z) / (2 * SEG_BAND)))
return [(top, w * (1.0 - fz)), (tip, w * fz)]
tau = 2 * math.pi
mw = o.matrix_world
for v in o.data.vertices:
co = mw @ v.co
if co.z >= hip_z:
continue
t = min(1.0, (hip_z - co.z) / max(1e-9, ramp_m))
az = math.atan2(co.y - pelvis.y, co.x - pelvis.x)
# bracket az between ring[i-1] and ring[i] (cyclic)
i = 0
while i < n and ring[i][0] <= az:
i += 1
a, b_ = ring[(i - 1) % n], ring[i % n]
span = (b_[0] - a[0]) % tau
f = ((az - a[0]) % tau) / span if span > 1e-9 else 0.0
# scale ALL existing (body) weights down, then add the two ring strands
for g in v.groups:
if g.group not in skirt_idx:
g.weight *= (1.0 - t)
for idx, w in strand_weights(a, co.z, t * (1.0 - f)) + strand_weights(b_, co.z, t * f):
if w > 0.0:
o.vertex_groups[idx].add([v.index], w, "ADD")
log(f"skirt-bone blend {o.name}: {n} strands, ramp {ramp_m * 1000:.0f}mm below hip")
def stage_skin():
src_stage = "40_bake" if os.path.exists(work_path("40_bake.blend")) else "30_reduce"
load_checkpoint(src_stage)
body, rig = obj("BODY"), obj("RIG")
hip_z = bone_head_z(rig, "pelvis")
for part_name, part_cfg in CFG["parts"].items():
p = bpy.data.objects.get(f"GARM_{part_name}")
if p is None:
continue
if part_cfg.get("type") == "bodyshell":
pass # weights inherited from the body copy — already correct
elif part_cfg.get("weights") == "dress":
# one-piece dress: body weights everywhere, then blend the loose
# skirt region toward pelvis-only as z approaches the hem
hem_z = zspan([p])[0]
data_transfer_weights(p, body)
pelvis_gradient(p, hip_z - 0.04, hem_z)
elif part_cfg.get("weights") == "skirt_bones":
# skirt_* ring bodies (make_skirt_rig_body.py): body weights at the
# waistband, azimuth-blended to the spring-simulated ring below the
# hip. The runtime capsules do what no static weighting can — move
# the cloth out of a lifted knee's way.
data_transfer_weights(p, body)
skirt_bone_blend(p, rig, hip_z - 0.04,
ramp_m=part_cfg.get("skirt_ramp_m", 0.20))
elif part_cfg.get("weights") == "skirt":
hem_z = zspan([p])[0]
cone = p.copy() # skirt proxy IS already a leg-bridging cone shape
cone.data = p.data.copy()
cone.name = f"CONE_{part_name}"
bpy.context.scene.collection.objects.link(cone)
data_transfer_weights(cone, body)
pelvis_gradient(cone, hip_z, hem_z)
smooth_and_normalize(cone)
data_transfer_weights(p, cone)
cone.hide_viewport = cone.hide_render = True
else:
data_transfer_weights(p, body)
smooth_and_normalize(p)
# bind
p.parent = rig
p.matrix_parent_inverse = Matrix.Identity(4)
am = p.modifiers.new("Armature", "ARMATURE")
am.object = rig
nonzero = sum(1 for v in p.data.vertices if v.groups)
log(f"skin {p.name}: weighted {nonzero}/{len(p.data.vertices)} verts")
qa_render("50_skin", focus_objects=objs_prefixed("GARM_"))
save_checkpoint("50_skin")
# ── stage: export ────────────────────────────────────────────────────────────
# One GLB per outfit slot (multiple parts may share a slot). Armature + parts
# only; BODY/HI/CONE stay out.
def stage_export():
load_checkpoint("50_skin")
rig = obj("RIG")
out_dir = CFG["export"]["out_dir"]
os.makedirs(out_dir, exist_ok=True)
slots = {}
for part_name, part_cfg in CFG["parts"].items():
p = bpy.data.objects.get(f"GARM_{part_name}")
if p is not None:
slots.setdefault(part_cfg["slot"], []).append(p)
gender, set_name = CFG["export"]["gender"], CFG["export"]["set"]
for slot, parts in slots.items():
path = os.path.join(out_dir, f"{gender}_{set_name}_{slot}.gltf")
select_only([rig] + parts, active=rig)
bpy.ops.export_scene.gltf(filepath=path, export_format="GLTF_SEPARATE",
use_selection=True, export_skins=True,
export_animations=False, export_yup=True)
log(f"EXPORTED {path} ({sum(tris_of(p) for p in parts)} tris, "
f"{os.path.getsize(path)} bytes)")
# determinism fingerprint
import hashlib
h = hashlib.sha1()
for part in sorted(objs_prefixed("GARM_"), key=lambda o: o.name):
for v in part.data.vertices:
h.update(b"%d %d %d" % (int(v.co.x * 1e5), int(v.co.y * 1e5), int(v.co.z * 1e5)))
log(f"vertex hash: {h.hexdigest()[:16]}")
# ── main ─────────────────────────────────────────────────────────────────────
STAGES = {"census": stage_census, "prepare": stage_prepare, "fit": stage_fit, "reduce": stage_reduce,
"bake": stage_bake, "skin": stage_skin, "export": stage_export}
def main():
global ARGS, CFG
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("--config", required=True)
ap.add_argument("--stage", required=True, choices=sorted(STAGES))
ARGS = ap.parse_args(argv)
cfg_path = ARGS.config if os.path.isabs(ARGS.config) else os.path.join(HERE, ARGS.config)
CFG = json.load(open(cfg_path, encoding="utf-8"))
log(f"config={CFG['name']} stage={ARGS.stage}")
STAGES[ARGS.stage]()
main()