One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:
Tools/unreal/ commandlets and the two map builders, with ue_common.py and ue_placement.py as the
shared imports (they need the engine)
Tools/blender/ builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
(they need the Blender Flatpak)
Tools/review/ the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
visual_targets.json
Tools/gen/ image and texture generation: the ComfyUI driver, deterministic texture writers,
reference fetching
Tools/input/ OS-level input injection
root _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/
Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.
51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.
Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
232 lines
8.6 KiB
Python
232 lines
8.6 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/blender/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.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())
|