Follows the character-artist clothing workflow literally: duplicate the body's own faces for the garment base, solidify for cloth thickness, and keep each piece a separate mesh. Attached as leader-pose components beside the hair, so no second rig. - Tools/character_clothing_build.py builds three garments from the body mesh, trimmed geometrically (height band plus a width limit) instead of by dominant bone: the bone filter left holes wherever a face's strongest bone was outside the allowed list. - Tools/ue_character_clothing_import.py imports them as CharacterTee / CharacterJeans / CharacterShoes, each arriving with its own single material slot. - The pawn attaches all three, hidden with the body in first person. Three traps recorded in MEMORY.md, each of which cost a build: MPFB weights its helper geometry to groups that dominate every vertex, Blender refuses to apply a modifier to a mesh with shape keys, and object-mode polygon selection is discarded on entering edit mode - the delete wiped the whole garment. Known defects, in TODO.md: the garments have coverage holes at shoulders, hips and knees, and the hair reads light and wispy since its material was repointed to the masked strand sheet.
219 lines
8.1 KiB
Python
219 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the character's clothing as separate skinned meshes.
|
|
|
|
The `character-artist` skill's clothing workflow, followed literally: duplicate body
|
|
faces for the clothing base, solidify for cloth thickness, and keep each piece a
|
|
separate mesh so it can be swapped or re-materialled on its own.
|
|
|
|
blender -b Art/blender/character/character-2026-11-07.blend \
|
|
--python Tools/character_clothing_build.py -- \
|
|
--out /tmp/gigline/clothing --report /tmp/gigline/clothing.json
|
|
|
|
Outputs one FBX per garment (`tee`, `jeans`, `shoes`), each carrying the character
|
|
armature so Unreal imports it as a skeletal mesh with the body's skeleton.
|
|
|
|
Selection is by **dominant vertex group**, not by height band alone: a z-range band
|
|
would swallow the arms hanging at the sides, and the arms have to stay bare below the
|
|
cap sleeves. Each garment lists the bones whose weights it accepts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import bpy
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import fbx_export_preset as fbx_preset # noqa: E402
|
|
|
|
GARMENTS = (
|
|
{
|
|
"name": "tee",
|
|
"asset": "SM_Char_Cloth_Tee",
|
|
"bones": ["spine01", "spine02", "spine03", "spine04", "spine05",
|
|
"clavicle.L", "clavicle.R", "shoulder01.L", "shoulder01.R",
|
|
"upperarm01.L", "upperarm01.R", "breast.L", "breast.R",
|
|
"neck01", "neck02", "pelvis.L", "pelvis.R"],
|
|
"z_min": 0.90, "z_max": 1.44,
|
|
"x_max": 0.235, # trims the bare arms out of the tee
|
|
"use_bones": False,
|
|
"thickness": 0.009,
|
|
"colour": (0.020, 0.032, 0.075, 1.0), # navy cotton
|
|
"roughness": 0.78,
|
|
},
|
|
{
|
|
"name": "jeans",
|
|
"asset": "SM_Char_Cloth_Jeans",
|
|
"bones": ["pelvis.L", "pelvis.R", "upperleg01.L", "upperleg01.R",
|
|
"upperleg02.L", "upperleg02.R", "lowerleg01.L", "lowerleg01.R",
|
|
"lowerleg02.L", "lowerleg02.R", "spine01"],
|
|
"z_min": 0.06, "z_max": 1.06,
|
|
"x_max": 0.30,
|
|
"use_bones": False,
|
|
"thickness": 0.010,
|
|
"colour": (0.032, 0.055, 0.105, 1.0), # dark denim
|
|
"roughness": 0.62,
|
|
},
|
|
{
|
|
"name": "shoes",
|
|
"asset": "SM_Char_Cloth_Shoes",
|
|
"bones": ["foot.L", "foot.R", "toe1-1.L", "toe1-1.R",
|
|
"lowerleg02.L", "lowerleg02.R"],
|
|
"z_min": -0.05, "z_max": 0.16,
|
|
"x_max": 0.40,
|
|
"use_bones": False,
|
|
"thickness": 0.012,
|
|
"colour": (0.72, 0.71, 0.68, 1.0), # off-white canvas
|
|
"roughness": 0.55,
|
|
},
|
|
)
|
|
|
|
PARTS: list[str] = []
|
|
|
|
|
|
def log(message: str) -> None:
|
|
PARTS.append(message)
|
|
print(f"[cloth] {message}")
|
|
|
|
|
|
# MPFB ships helper geometry in the same mesh, weighted 1.0 to groups called
|
|
# 'Left', 'Right', 'HelperGeometry' and 'JointCubes'. Those dominate the raw weights,
|
|
# so the first build selected zero faces: every vertex's strongest group was a helper.
|
|
_HELPER_PREFIXES = ("helper", "joint", "Helper", "Joint")
|
|
_HELPER_NAMES = {"Left", "Right", "Mid", "body", "HelperGeometry", "JointCubes"}
|
|
|
|
|
|
def is_deform_bone(name: str) -> bool:
|
|
return bool(name) and not name.startswith(_HELPER_PREFIXES) and name not in _HELPER_NAMES
|
|
|
|
|
|
def dominant_groups(mesh) -> dict[int, str]:
|
|
"""Vertex index -> its strongest *deforming bone* group, ignoring helpers."""
|
|
index_to_name = {g.index: g.name for g in mesh.vertex_groups}
|
|
result = {}
|
|
for vertex in mesh.data.vertices:
|
|
candidates = [(g.weight, index_to_name.get(g.group, "")) for g in vertex.groups]
|
|
candidates = [(w, n) for w, n in candidates if is_deform_bone(n)]
|
|
if not candidates:
|
|
continue
|
|
result[vertex.index] = max(candidates)[1]
|
|
return result
|
|
|
|
|
|
def build_garment(body, arm, spec, out_dir):
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
body.select_set(True)
|
|
bpy.context.view_layer.objects.active = body
|
|
bpy.ops.object.duplicate()
|
|
garment = bpy.context.active_object
|
|
garment.name = spec["asset"]
|
|
|
|
allowed = set(spec["bones"])
|
|
dominant = dominant_groups(body)
|
|
|
|
mesh = garment.data
|
|
# Filter with bmesh instead of edit-mode selection. Setting polygon.select in
|
|
# object mode and then entering edit mode re-derives selection from the vertices,
|
|
# which were still all selected - the delete wiped the whole garment every time.
|
|
import bmesh
|
|
bm = bmesh.new()
|
|
bm.from_mesh(mesh)
|
|
doomed = []
|
|
for face in bm.faces:
|
|
centre_z = sum(v.co.z for v in face.verts) / len(face.verts)
|
|
groups = [dominant.get(v.index, "") for v in face.verts]
|
|
centre_x = abs(sum(v.co.x for v in face.verts) / len(face.verts))
|
|
keep = (spec["z_min"] <= centre_z <= spec["z_max"]
|
|
and centre_x <= spec.get("x_max", 10.0)
|
|
and (not spec.get("use_bones") or any(g in allowed for g in groups)))
|
|
face.tag = keep
|
|
if not keep:
|
|
doomed.append(face)
|
|
bmesh.ops.delete(bm, geom=doomed, context="FACES")
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
mesh.update()
|
|
log(f"{spec['asset']}: kept {len(mesh.polygons)} faces")
|
|
|
|
if not mesh.polygons:
|
|
log(f"{spec['asset']}: no faces selected - check the bone list and z range")
|
|
return None
|
|
|
|
# The body carries MPFB shape keys, and Blender refuses to apply a modifier to a
|
|
# mesh that has any. A garment does not need the morph targets.
|
|
if garment.data.shape_keys:
|
|
bpy.ops.object.shape_key_remove(all=True)
|
|
|
|
# Cloth thickness: solidify outward so the garment sits over the body rather
|
|
# than inside it.
|
|
solidify = garment.modifiers.new(name="Cloth", type="SOLIDIFY")
|
|
solidify.thickness = spec["thickness"]
|
|
solidify.offset = 1.0
|
|
bpy.ops.object.modifier_apply(modifier=solidify.name)
|
|
|
|
material = bpy.data.materials.get(f"M_Char_{spec['name']}") \
|
|
or bpy.data.materials.new(f"M_Char_{spec['name']}")
|
|
material.use_nodes = True
|
|
bsdf = material.node_tree.nodes.get("Principled BSDF")
|
|
if bsdf:
|
|
bsdf.inputs["Base Color"].default_value = spec["colour"]
|
|
bsdf.inputs["Roughness"].default_value = spec["roughness"]
|
|
garment.data.materials.clear()
|
|
garment.data.materials.append(material)
|
|
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
arm.select_set(True)
|
|
garment.select_set(True)
|
|
bpy.context.view_layer.objects.active = arm
|
|
path = os.path.join(out_dir, f"{spec['name']}.fbx")
|
|
fbx_preset.export_character(path, selection=True)
|
|
|
|
stats = {
|
|
"asset": spec["asset"],
|
|
"file": path,
|
|
"vertices": len(garment.data.vertices),
|
|
"triangles": sum(len(p.vertices) - 2 for p in garment.data.polygons),
|
|
"thickness_m": spec["thickness"],
|
|
"bones": spec["bones"],
|
|
"z_range": [spec["z_min"], spec["z_max"]],
|
|
"colour": spec["colour"],
|
|
"roughness": spec["roughness"],
|
|
}
|
|
log(f"{spec['asset']}: {stats['vertices']} verts, {stats['triangles']} tris -> {path}")
|
|
return stats
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--out", default="/tmp/gigline/clothing")
|
|
ap.add_argument("--report", default=None)
|
|
args = ap.parse_args(argv)
|
|
|
|
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
|
|
body = next((o for o in bpy.data.objects if o.type == "MESH" and o.parent == arm), None)
|
|
if arm is None or body is None:
|
|
raise SystemExit("need the character armature and its body mesh")
|
|
log(f"body {body.name}: {len(body.data.vertices)} verts")
|
|
|
|
os.makedirs(args.out, exist_ok=True)
|
|
built = []
|
|
for spec in GARMENTS:
|
|
stats = build_garment(body, arm, spec, args.out)
|
|
if stats:
|
|
built.append(stats)
|
|
|
|
if args.report:
|
|
with open(args.report, "w") as handle:
|
|
json.dump({"garments": built, "log": PARTS}, handle, indent=2)
|
|
print(f"[cloth] report -> {args.report}")
|
|
return 0 if len(built) == len(GARMENTS) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|