feat(clothing): tee, jeans and shoes as separate skinned meshes

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.
This commit is contained in:
zwitschi committed 2026-11-09 13:41:39 +01:00
1 parent 85a8437cbc
commit 86e9c5b693
50 files changed
+408 -46

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
#!/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())
+71
View File
@@ -0,0 +1,71 @@
# Import the garment meshes as separate skeletal meshes (0.2.2).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/character_clothing_build.py -- --out /tmp/gigline/clothing
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_clothing_import.py -nullrhi -unattended -nopause -stdout
#
# Each garment is its own asset, per the character-artist skill's rule that clothing
# stays separate so it can be swapped or re-materialled. The pawn attaches each one as
# a component using the body as the leader pose.
#
# Result file: /tmp/gigline/character-clothing-import.txt
import unreal
DEST = "/Game/Test/Character"
SOURCE = "/tmp/gigline/clothing"
GARMENTS = (("tee", "CharacterTee"), ("jeans", "CharacterJeans"), ("shoes", "CharacterShoes"))
RESULT = "/tmp/gigline/character-clothing-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
for source, name in GARMENTS:
task = unreal.AssetImportTask()
task.filename = f"{SOURCE}/{source}.fbx"
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = True
ui.import_animations = False
ui.import_materials = True
ui.import_textures = False
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if mesh is None:
log(f"{name}: MISSING after import")
lines.append("VERDICT: FAIL")
return
mats = mesh.get_editor_property("materials")
log(f"{name}: imported, {len(mats)} slot(s): "
+ ", ".join((m.get_editor_property("material_interface").get_name()
if m.get_editor_property("material_interface") else "None")
for m in mats))
lines.append("VERDICT: PASS")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")