218 lines
9.3 KiB
Python
218 lines
9.3 KiB
Python
#!/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
|
|
|
|
The result is assets/characters/skins/<name>.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("<III", f.read(12))
|
|
if magic != 0x46546C67:
|
|
return False
|
|
while f.tell() < total:
|
|
length, ctype = struct.unpack("<II", f.read(8))
|
|
chunk = f.read(length)
|
|
if ctype == 0x4E4F534A: # JSON
|
|
return bool(json.loads(chunk.decode("utf-8")).get("skins"))
|
|
except (OSError, ValueError, struct.error) as e:
|
|
print(f"WARNING: could not probe '{path}' for a skeleton ({e})")
|
|
return False
|
|
|
|
|
|
def run(cmd: list[str], step: str) -> 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")
|
|
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")
|
|
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)")
|
|
|
|
# 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()
|