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:
1 parent
85a8437cbc
commit
86e9c5b693
50 files changed
+408
-46
No files matched your search
@@ -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())
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user