Add the external-mesh adapt intake command for the MakeHuman pipeline
Full build / Import + headless tests (push) Successful in 46s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-04 11:27:43 +01:00
1 parent e984129597
commit 28fcd96b59
7 files changed
+130 -20

No files matched your search

+89
View File
@@ -2119,6 +2119,74 @@ def inspect_glb(path, category=None) -> dict:
return result
def adapt_glb(path, out_path, target_tris=8000):
"""Import an external GLB and adapt it toward the library contract.
Applies node transforms, grounds the feet (lowest vertex at y = 0), welds
duplicated seam vertices and decimates to `target_tris`, then exports the
result to `out_path` as a GLB. The five tint slots and the 13-bone contract
armature are deliberately NOT auto-mapped — that is a manual retopology/rig
step. Returns before/after measurements for the intake report.
"""
if bpy is None:
return {"ok": False, "error": "adapt must run inside Blender"}
file_path = Path(path).expanduser()
if not file_path.is_file():
return {"ok": False, "error": f"missing file {file_path}"}
reset_scene()
import_log = io.StringIO()
try:
with contextlib.redirect_stdout(import_log), contextlib.redirect_stderr(import_log):
bpy.ops.import_scene.gltf(filepath=str(file_path))
except RuntimeError as exc:
return {"ok": False, "error": f"import failed: {exc}"}
armatures = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
helpers = {pb.custom_shape.name for arm in armatures for pb in arm.pose.bones
if pb.custom_shape is not None}
meshes = [o for o in bpy.context.scene.objects
if o.type == "MESH" and o.name not in helpers]
if not meshes:
return {"ok": False, "error": "no meshes in the file"}
_apply_imported_transforms(meshes, armatures)
before = sum(_triangle_count(o) for o in meshes)
# Ground: translate so the lowest vertex sits at y = 0.
min_y = min((o.matrix_world @ v.co).y for o in meshes for v in o.data.vertices)
for o in meshes:
o.location.y -= min_y
# Weld seam-split duplicates left by the glTF importer.
for o in meshes:
bm = bmesh.new()
bm.from_mesh(o.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
bm.to_mesh(o.data)
bm.free()
# Decimate to the target budget (modifier applied so the count is real).
total = sum(_triangle_count(o) for o in meshes)
if total > target_tris:
ratio = max(0.05, target_tris / total)
for o in meshes:
o.modifiers.new("AdaptDecimate", "DECIMATE").ratio = ratio
_apply_all(o)
after = sum(_triangle_count(o) for o in meshes)
bpy.ops.object.select_all(action="DESELECT")
for o in meshes:
o.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
out = Path(out_path).expanduser()
out.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=str(out), export_format="GLB", use_selection=True,
export_apply=True, export_animations=False, export_skins=True,
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
return {"ok": True, "before_triangles": before, "after_triangles": after,
"out": str(out), "bones": [b.name for a in armatures for b in a.data.bones],
"meshes": [o.name for o in meshes]}
def _to_godot(co, digits: int = 4) -> list:
"""Blender Z-up (x, y, z) -> Godot/glTF Y-up (x, z, -y) so reports match
the contract bounds, HEAD_BAND and the audit's coordinate space."""
@@ -2449,6 +2517,12 @@ def main(argv=None):
insp.add_argument("--json", action="store_true")
insp.add_argument("--out", default="",
help="with --json, write to this file (Blender prints a startup banner)")
ada = sub.add_parser("adapt", help="import + ground + weld + decimate an external GLB")
ada.add_argument("--file", required=True,
help="external GLB to adapt (absolute path; runs in Blender)")
ada.add_argument("--out", required=True, help="where to write the adapted GLB")
ada.add_argument("--target-tris", type=int, default=8000,
help="decimate target (default 8000)")
tex = sub.add_parser("texels", help="per-slot texel density of the character GLBs")
tex.add_argument("--only", default="")
tex.add_argument("--json", action="store_true")
@@ -2498,6 +2572,21 @@ def main(argv=None):
ok = all(r.get("ok", False) for r in results)
return 0 if ok else 1
if args.cmd == "adapt":
if bpy is None:
print("adapt must run inside Blender:\n flatpak run org.blender.Blender "
f"--background --python {Path(__file__).resolve()} -- adapt "
"--file <in.glb> --out <out.glb>", file=sys.stderr)
return 2
result = adapt_glb(args.file, args.out, args.target_tris)
if not result.get("ok"):
print(f"ADAPT FAILED {result.get('error', 'unknown error')}", file=sys.stderr)
return 1
print(f"adapted {Path(result['out']).name}: "
f"{result['before_triangles']} -> {result['after_triangles']} tris"
f" (bones={len(result['bones'])}, meshes={len(result['meshes'])})")
return 0
if args.cmd in ("build", "render"):
if bpy is None:
print(f"{args.cmd} must run inside Blender:\n flatpak run org.blender.Blender "