#!/usr/bin/env python3 """PROCITY — Mixamo character FBX -> raw ped GLB (R42 §42.3, Lane E cast half). The first stage of the ped contract pipeline. The shipped roster was made this way in R4/R7 but the FBX->GLB step was never scripted in this repo (it lived in 90sDJsim), so recasting the town had no reproducible front end. This is it. Stages 2 and 3 are unchanged and stay where they are: 1. THIS Blender: import Mixamo FBX -> export GLB (units + skeleton preserved) 2. decimate npx @gltf-transform/cli weld -> simplify --ratio R --error E (AUDIT.md R4) 3. merge pipeline/merge_ped.py -> 1 primitive / 1 material / 1 atlas (AUDIT.md R7) WHAT MUST SURVIVE (measured off web/models/peds/man_casual_01.glb, the reference ped): 67 nodes = 65 `mixamorigN:` joints + 1 mesh node + 1 `Armature` 1 skin, 65 joints, the canonical Mixamo bone-name set `rigs.js _canon` rewrites `mixamorig\\d+` -> `mixamorig`, so the numeric infix may differ per file (the shipped peds are `mixamorig12:`, walk.glb is `mixamorig4:`) but the COLON and the bone base names may not: `_action()` filters an incoming clip's tracks to bones that exist on the target, so a renamed bone does not error — it silently drops the track and the citizen stands in bind pose. That is the whole reason this stage is scripted instead of hand-run. BL=/Applications/Blender.app/Contents/MacOS/Blender "$BL" --background --python pipeline/ped_from_fbx.py -- OUT_DIR NAME=SRC.fbx [NAME=SRC.fbx ...] Import is DEFAULT-SCALE on purpose. Mixamo FBX are centimetre-unit, so the GLB comes out ~177 units tall — exactly like the shipped roster (man_casual_01: 178.01 x 175.98 x 34.75). `buildFigure()` normalizes by the feet->crown bone span at runtime, so unit-matching the existing fleet matters more than being metre-correct here, and rescaling would make the new peds the odd ones out. """ import bpy, sys, os ARGV = sys.argv[sys.argv.index("--") + 1:] OUT_DIR, pairs = ARGV[0], ARGV[1:] os.makedirs(OUT_DIR, exist_ok=True) def wipe(): for o in list(bpy.data.objects): bpy.data.objects.remove(o, do_unlink=True) for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images, bpy.data.armatures, bpy.data.actions): for d in list(coll): try: coll.remove(d) except Exception: pass ok = fail = 0 for pair in pairs: name, _, src = pair.partition("=") if not src: print(f"BADARG {pair}"); fail += 1; continue wipe() try: bpy.ops.import_scene.fbx(filepath=src, use_image_search=True, automatic_bone_orientation=False) except Exception as e: print(f"IMPORT FAIL {name}: {e}"); fail += 1; continue arms = [o for o in bpy.data.objects if o.type == 'ARMATURE'] meshes = [o for o in bpy.data.objects if o.type == 'MESH'] if not arms or not meshes: print(f"NO RIG/MESH {name}: arms={len(arms)} meshes={len(meshes)}"); fail += 1; continue bones = [b.name for b in arms[0].data.bones] mixa = sum(1 for b in bones if b.lower().startswith("mixamorig")) # SCALE SHAPE — match the base roster, do not merely be normalizable. # Blender's FBX importer lands a Mixamo character as metre-scale geometry under an Armature # whose object scale is 0.01, so the exported GLB is a 1.76-unit mesh under a 0.01 root = # 0.0176 world units tall. `buildFigure()` normalizes by the feet->crown BONE SPAN, so that # animates correctly at any scale — the shipped DJ five are exactly this shape and have run # for rounds. But the base 17 are the other shape (centimetre positions, no root scale: # man_casual_01 POSITION spans -89..+89, root scale absent), and a replacement joining that # pool should not be the one member with a 10^4 difference in vertex magnitude feeding the # same impostor bake. Applying the armature scale here makes the output byte-shaped like the # pool it joins and removes the variable rather than arguing about it. for a in arms: a.select_set(True) bpy.context.view_layer.objects.active = a bpy.ops.object.select_all(action='DESELECT') for o in bpy.data.objects: o.select_set(True) bpy.context.view_layer.objects.active = arms[0] try: bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) except Exception as e: print(f"SCALE APPLY WARN {name}: {e}") # Mixamo FBX carry a T-pose "mixamo.com" action; the ped is a MESH asset, the clips come from # the shared bank, so drop every action before export. A ped GLB with an animation would make # spawnRig's `rig.anims[0]` fallback prefer a T-pose over the fleet idle. for o in bpy.data.objects: if o.animation_data: o.animation_data_clear() for a in list(bpy.data.actions): try: bpy.data.actions.remove(a) except Exception: pass out = os.path.join(OUT_DIR, f"{name}.glb") try: bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', export_animations=False, export_skins=True, export_morph=False, export_apply=False, export_yup=True, export_image_format='AUTO') except Exception as e: print(f"EXPORT FAIL {name}: {e}"); fail += 1; continue kb = os.path.getsize(out) // 1024 print(f"OK {name:26s} bones={len(bones):3d} mixamorig={mixa:3d} meshes={len(meshes)} " f"{kb} KB -> {out}") ok += 1 print(f"PED_FROM_FBX done ok={ok} fail={fail}")