refactor(tools): group the sixty scripts by what they talk to
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.
This commit is contained in:
1 parent
f681d83736
commit
4d27109668
168 files changed
+799
-407
No files matched your search
@@ -0,0 +1,231 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user