#!/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.255, # trims the bare arms out of the tee "use_bones": False, "thickness": 0.020, "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.32, "use_bones": False, "thickness": 0.020, "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) # Push the shell out along its own normals. Solidify alone left the body visible # through the garment at every convex patch - shoulders, hips, knees. import bmesh bm = bmesh.new() bm.from_mesh(mesh) bm.normal_update() for vertex in bm.verts: vertex.co += vertex.normal * spec.get("fatten", 0.008) bm.to_mesh(mesh) bm.free() mesh.update() log(f"{spec['asset']}: fattened by {spec.get('fatten', 0.008):.3f} m") 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())