#!/usr/bin/env python3 """ One-command character pipeline for Papaya-Shooter: Sketchfab model -> auto-rig -> merge animation library -> in game. Examples: # From a Sketchfab UID (needs SKETCHFAB_API_TOKEN): python tools/pipeline.py --uid a1b2c3... --name space_marine # From a local unrigged mesh: python tools/pipeline.py --input downloads/robot.glb --name robot # From an ALREADY-RIGGED model (Mixamo/AccuRig/Tripo output) — skips autorig: python tools/pipeline.py --input rigged/knight.fbx --name knight --rigged # Rigged, but with no skirt/hair bones — grow them so the costume can move: python tools/pipeline.py --input rigged/knight.fbx --name knight --rigged --grow-cloth The result is assets/characters/skins/.glb with the full canonical animation set, plus a registry entry in assets/characters/skins/skins.json that SkinManager picks up automatically — no code changes needed. Requires Blender 3.6+ on PATH or BLENDER_PATH set. Animation library: assets/characters/animations/ (see docs/ASSET_SOURCES.md). """ import argparse import json import os import shutil import struct import subprocess import sys PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TOOLS = os.path.join(PROJECT_ROOT, "tools") SKINS_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "skins") ANIM_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "animations") STAGING = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming") SKINS_JSON = os.path.join(SKINS_DIR, "skins.json") def find_blender() -> str: env = os.environ.get("BLENDER_PATH") if env and os.path.exists(env): return env on_path = shutil.which("blender") if on_path: return on_path candidates = [] for pf in (r"C:\Program Files\Blender Foundation", r"C:\Program Files (x86)\Blender Foundation"): if os.path.isdir(pf): for d in sorted(os.listdir(pf), reverse=True): exe = os.path.join(pf, d, "blender.exe") if os.path.exists(exe): candidates.append(exe) if candidates: return candidates[0] print("ERROR: Blender not found. Install Blender or set BLENDER_PATH to blender.exe") sys.exit(1) def has_skeleton(path: str) -> bool: """Does this glTF already carry a skin? Read straight out of the container so the check costs nothing — launching Blender just to ask takes seconds. Non-glTF formats can't be probed this way; pass --rigged for those. """ ext = os.path.splitext(path)[1].lower() try: if ext == ".gltf": with open(path, "r", encoding="utf-8") as f: return bool(json.load(f).get("skins")) if ext != ".glb": return False with open(path, "rb") as f: magic, _ver, total = struct.unpack(" None: print(f"\n=== {step} ===") print(" ".join(f'"{c}"' if " " in c else c for c in cmd)) result = subprocess.run(cmd) if result.returncode != 0: print(f"ERROR: step '{step}' failed (exit {result.returncode})") sys.exit(result.returncode) def register_skin(name: str, display_name: str, model_res_path: str, description: str) -> None: registry = {"skins": []} if os.path.exists(SKINS_JSON): with open(SKINS_JSON, "r", encoding="utf-8") as f: registry = json.load(f) skins = registry.setdefault("skins", []) entry = { "id": name, "name": display_name, "description": description, "model": model_res_path, "unlocked": True, } for i, s in enumerate(skins): if s.get("id") == name: skins[i] = entry break else: skins.append(entry) os.makedirs(SKINS_DIR, exist_ok=True) with open(SKINS_JSON, "w", encoding="utf-8") as f: json.dump(registry, f, indent=2) print(f"Registered skin '{name}' in {os.path.relpath(SKINS_JSON, PROJECT_ROOT)}") def main() -> None: p = argparse.ArgumentParser(description="Sketchfab -> rigged, animated, in-game character") src = p.add_mutually_exclusive_group(required=True) src.add_argument("--uid", help="Sketchfab model UID to download") src.add_argument("--input", help="local model file (glb/gltf/fbx/obj)") p.add_argument("--name", required=True, help="skin id (snake_case)") p.add_argument("--display-name", help="name shown in menus (default: from --name)") p.add_argument("--description", default="", help="skin description") p.add_argument("--rigged", action="store_true", help="force the keep-the-rig path (auto-detected for glTF)") p.add_argument("--rebind", action="store_true", help="discard the source rig and fit the library skeleton " "(last resort — destroys authored weights and cloth bones)") p.add_argument("--height", type=float, default=1.75, help="target character height in meters") p.add_argument("--keep-root-motion", action="store_true", help="don't strip hips motion from clips") p.add_argument("--anim-dir", default=ANIM_DIR, help="animation library directory") p.add_argument("--grow-cloth", action="store_true", help="grow skirt/hair bone chains for a model that has none " "(see tools/cloth_bones.py) — needed for anything that " "went through the auto-rig, whose costume is welded solid") args = p.parse_args() name = args.name display_name = args.display_name or name.replace("_", " ").title() os.makedirs(STAGING, exist_ok=True) # 1. Acquire the model. if args.uid: run([sys.executable, os.path.join(TOOLS, "sketchfab_import.py"), "download", args.uid, "--name", name, "--out", STAGING], "Download from Sketchfab") input_path = os.path.join(STAGING, f"{name}.glb") if not os.path.exists(input_path): # glTF-zip fallback path used by the importer alt = os.path.join(STAGING, name) gltfs = [f for f in os.listdir(alt) if f.endswith((".gltf", ".glb"))] if os.path.isdir(alt) else [] if not gltfs: print("ERROR: downloaded model not found in staging dir") sys.exit(1) input_path = os.path.join(alt, gltfs[0]) else: input_path = os.path.abspath(args.input) if not os.path.exists(input_path): print(f"ERROR: input not found: {input_path}") sys.exit(1) blender = find_blender() print(f"Using Blender: {blender}") # 2. Decide whether the model already has a skeleton worth keeping. # # Keeping it is strongly preferred and is now the default. The old route # (strip_rig -> autorig) discarded the source skeleton, joined every mesh # into one, and rebound with nearest-bone weights — which is what put 2817 # both-legs-at-once vertices into the shipped Taila and cost her the skirt # and hair bone chains outright. --rebind still exists for a model that # genuinely has no usable rig, but it is the lossy path. rigged = args.rigged or (not args.rebind and has_skeleton(input_path)) if not os.path.isdir(args.anim_dir) or not os.path.exists( os.path.join(args.anim_dir, "_library.glb")): print(f"ERROR: animation library not found: {args.anim_dir}/_library.glb") print("See docs/ASSET_SOURCES.md") sys.exit(1) final_path = os.path.join(SKINS_DIR, f"{name}.glb") # Grow cloth chains BEFORE the retarget, so describe_rig() finds them by name # exactly as it would an artist's and writes them to the sidecar. # # A model with no cosmetic bones has a costume that cannot move: the spring # solver simulates cloth BONES, so a skirt with none is welded to the hips # whatever the runtime does. That is every auto-rigged model, and it is why # Miku shipped with 0 cloth chains against Taila's 35. if args.grow_cloth: grown = os.path.join(STAGING, f"{name}_cloth.glb") run([blender, "--background", "--python", os.path.join(TOOLS, "cloth_bones.py"), "--", input_path, grown], "Grow skirt/hair bone chains (Blender)") input_path = grown rigged = True if rigged: print("Model is rigged — keeping its skeleton, weights and cloth chains") cmd = [blender, "--background", "--python", os.path.join(TOOLS, "retarget.py"), "--", input_path, args.anim_dir, final_path, "--height", str(args.height)] if args.keep_root_motion: cmd.append("--keep-root-motion") run(cmd, "Retarget animation library onto the model's own rig (Blender)") else: print("No skeleton found — fitting the library rig (authored weights unavailable)") rigged_path = os.path.join(STAGING, f"{name}_rigged.glb") run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"), "--", input_path, rigged_path, str(args.height)], "Auto-rig (Blender)") cmd = [blender, "--background", "--python", os.path.join(TOOLS, "retarget.py"), "--", rigged_path, args.anim_dir, final_path, "--height", str(args.height)] if args.keep_root_motion: cmd.append("--keep-root-motion") run(cmd, "Retarget animation library (Blender)") # Blender's large NLA export can omit actions imported from the final # animation libraries on some FBX/Auto-Rig Pro and Mixamo rigs. Re-inject # the three critical late-library clips in compact passes. Besides making # the build deterministic, this prevents backward movement and wall-running # from silently falling through to unrelated ground/fall animations. injector = os.path.join(TOOLS, "inject_wallrun.py") wall_library = os.path.join(args.anim_dir, "_wallrun.glb") backward_library = os.path.join(args.anim_dir, "_directional.glb") wall_fixed = os.path.join(STAGING, f"{name}_wall_motion.glb") fully_fixed = os.path.join(STAGING, f"{name}_required_motion.glb") run([blender, "--background", "--python", injector, "--", final_path, wall_library, wall_fixed, "WallRunLeft", "WallRunRight"], "Guarantee authored wall-run clips (Blender)") run([blender, "--background", "--python", injector, "--", wall_fixed, backward_library, fully_fixed, "strafe_back-cycle=RunBackward"], "Guarantee authored backward locomotion (Blender)") shutil.copyfile(fully_fixed, final_path) # 3. Gate on the checks that encode every way this has gone wrong before. run([blender, "--background", "--python", os.path.join(TOOLS, "verify_character.py"), "--", final_path], "Verify the built character (Blender)") # 4. Carry the license file along if the model came from Sketchfab. lic_src = os.path.splitext(input_path)[0] + ".license.json" if os.path.exists(lic_src): shutil.copyfile(lic_src, os.path.join(SKINS_DIR, f"{name}.license.json")) # 5. Register so the game sees it. register_skin(name, display_name, f"res://assets/characters/skins/{name}.glb", args.description) print(f"\nDONE: {os.path.relpath(final_path, PROJECT_ROOT)}") print(f"'{display_name}' is now selectable in-game (SkinManager reads skins.json).") if __name__ == "__main__": main()