"""CampGuide after Studio: game FBX, Mixamo TokenRig (40-50 bones), Kimodo in-place idle and walk.""" from __future__ import annotations import json import shutil import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] STUDIO = Path(r"D:\UD\AI\XModel\LLM3DGraphics") BLENDER = Path(r"D:\Tools\Blender\blender-5.0.0-windows-x64\blender.exe") MESH = ROOT / "Tools/output/campguide_studio" NAME = "CampGuideStudio" UNITY = ROOT / "AIClient/Assets/Bundles/Characters/Human/CampGuideStudio" RIG = MESH / "rig" MOTIONS = [ ("Idle", "a young boy stands in place and breathes gently, idle, feet planted, no walking"), ("Walk", "a young boy walks in place with alternating steps, no forward travel, gentle arm swing"), ] CONVERT = r''' import sys from pathlib import Path import bpy from mathutils import Matrix from math import pi src, dst = [Path(p) for p in sys.argv[sys.argv.index("--") + 1:]] bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.fbx(filepath=str(src)) if any(o.type == "ARMATURE" for o in bpy.context.scene.objects): raise SystemExit("expected a static mesh") for obj in bpy.context.scene.objects: if obj.parent is None: obj.matrix_world = Matrix.Rotation(pi / 2, 4, "X") @ obj.matrix_world dst.parent.mkdir(parents=True, exist_ok=True) bpy.ops.export_scene.gltf(filepath=str(dst), export_format="GLB", export_yup=True) print("OK", dst) ''' def joints(glb: Path) -> list[str]: data = glb.read_bytes() if data[:4] != b"glTF": raise RuntimeError(f"not a glb: {glb}") import struct json_size = struct.unpack_from(" None: log.parent.mkdir(parents=True, exist_ok=True) with log.open("w", encoding="utf-8") as handle: completed = subprocess.run(cmd, stdout=handle, stderr=subprocess.STDOUT, timeout=timeout) if completed.returncode != 0: tail = log.read_text(encoding="utf-8", errors="replace")[-2000:] raise RuntimeError(f"command failed ({completed.returncode}): {tail}") def export_static() -> Path: if not (MESH / "model_ex.glb").is_file(): raise SystemExit("Studio mesh is not ready") fbx = MESH / "static" / f"{NAME}.fbx" if not fbx.is_file(): run( [str(BLENDER), "-b", "-t", "1", "--factory-startup", "--python", str(ROOT / "Tools/model_tools/bl_prepare_studio_trial.py"), "--", str(MESH), NAME], MESH / "static_export.log", 180, ) sys.path.insert(0, str(ROOT / "Tools/model_tools")) from mt_run import run_blender_script from png_compress import compress_dir_pngs xpbr = MESH / "xpbr" if not (xpbr / f"{NAME}_base.png").is_file(): run_blender_script(str(BLENDER), "bl_convert.py", [str(fbx), str(xpbr), "1024"], blender_args=("-t", "1")) compress_dir_pngs(str(xpbr), suffixes=("_base.png", "_mix.png")) return fbx def rig_mixamo(fbx: Path) -> Path: sys.path.insert(0, str(STUDIO)) from backend.motion_branches import apply_mixamo_profile from backend.tokenrig_runner import run_tokenrig RIG.mkdir(parents=True, exist_ok=True) source = RIG / "source.glb" if not source.is_file(): script = RIG / "_convert.py" script.write_text(CONVERT, encoding="utf-8") run([str(BLENDER), "-b", "--factory-startup", "--python", str(script), "--", str(fbx), str(source)], RIG / "convert.log", 180) ready = RIG / "mixamo_ready.glb" # The default vroid sample stopped at a 23-bone body. rignet is the densest # complete Mixamo sample this mitten-handed mesh produced. chosen = RIG / "rignet.glb" fbx = RIG / "mixamo_ready.fbx" if chosen.is_file() and not (RIG / "skeleton.json").is_file(): if fbx.is_file(): fbx.unlink() apply_mixamo_profile(chosen, RIG, RIG / "tokenrig.log") elif not ready.is_file(): rigged = Path(run_tokenrig(source, RIG, RIG / "tokenrig.log", skeleton_template="mixamo")) apply_mixamo_profile(rigged, RIG, RIG / "tokenrig.log") names = joints(ready) mixamo = [name for name in names if name.startswith("mixamorig:")] report = {"joints": len(names), "mixamo": len(mixamo), "names": names} (RIG / "skeleton.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") required = {"mixamorig:Hips", "mixamorig:Head", "mixamorig:LeftHand", "mixamorig:RightHand", "mixamorig:LeftFoot", "mixamorig:RightFoot"} if not required.issubset(names) or not 30 <= len(mixamo) <= 65: raise SystemExit(f"Mixamo skeleton rejected: {len(mixamo)} mixamorig joints. See {RIG / 'skeleton.json'}") if not (RIG / "mixamo_ready.fbx").is_file(): raise SystemExit("mixamo_ready.fbx was not exported") return ready def animate(ready: Path) -> None: sys.path.insert(0, str(STUDIO)) from backend.kimodo_runner import submit_kimodo_job for label, prompt in MOTIONS: dest = RIG / label if (dest / "animated.fbx").is_file(): continue work = RIG / f"_work_{label}" if work.exists(): shutil.rmtree(work) work.mkdir() submit_kimodo_job(rig_path=ready, prompt=prompt, directory=work, log_path=work / "kimodo.log", job_id=label) dest.mkdir(parents=True, exist_ok=True) for name in ("animated.fbx", "animated.glb", "kimodo_motion.bvh"): shutil.copy2(work / name, dest / name) def install() -> None: for folder in (UNITY, UNITY / "Rigged", UNITY / "Anims"): folder.mkdir(parents=True, exist_ok=True) shutil.copy2(MESH / "xpbr" / f"{NAME}_base.png", UNITY / f"{NAME}_base.png") shutil.copy2(MESH / "xpbr" / f"{NAME}_mix.png", UNITY / f"{NAME}_mix.png") import zipfile package = RIG / "mixamo_ready.fbx" with zipfile.ZipFile(package) as archive: archive.extract("model.fbx", UNITY / "Rigged") (UNITY / "Rigged" / "model.fbx").replace(UNITY / "Rigged" / f"{NAME}_Rigged.fbx") shutil.copy2(RIG / "Idle" / "animated.fbx", UNITY / "Anims" / f"{NAME}_Idle.fbx") shutil.copy2(RIG / "Walk" / "animated.fbx", UNITY / "Anims" / f"{NAME}_Walk.fbx") def main() -> int: fbx = export_static() ready = rig_mixamo(fbx) animate(ready) install() skeleton = json.loads((RIG / "skeleton.json").read_text(encoding="utf-8")) print(json.dumps({"ok": True, "mixamoJoints": skeleton["mixamo"], "unity": str(UNITY)}, ensure_ascii=False), flush=True) return 0 if __name__ == "__main__": raise SystemExit(main())