"""Build web/world/models/player_anims.glb -- one anim-only GLB carrying every clip the player needs. Run on the M1 Ultra (the FBX libraries live there). The OUTPUT glb is committed per the copies rule, so the game itself never needs this box: scp tools/character/build_player_anims.py johnking@100.91.239.7:~/Documents/shades-laneD-out/ ssh johnking@100.91.239.7 '/Applications/Blender.app/Contents/MacOS/Blender -b --factory-startup \ -P ~/Documents/shades-laneD-out/build_player_anims.py' scp johnking@100.91.239.7:~/Documents/shades-laneD-out/player_anims.glb web/world/models/ This is the 90sDJsim peds/idle.glb + peds/walk.glb shape (an anim-only clip carrier, no mesh), extended to the clip set SHADES needs. The character itself is a byte copy of a ped -- see MODELS.md. WHY NOT merge the clips onto the ped, character_kit/scripts/merge_anims.py style (which is what PLAN3D 5-D.1 assumes): The ped GLBs encode their metre scale as a NODE scale of 0.01 on mixamorig*:Hips, with every child bone in centimetres (Spine T=+10.05, LeftLeg T=+42.8). Blender bones have no rest scale, so the glTF importer silently drops that 0.01: straight after import the armature already reads Hips.head_local=(0,-0.03,0.99) in METRES while HeadTop_End=(0,-4.39,76.88) is in CENTIMETRES, and LeftToe_End sits 96 m under the floor. The rig is exploded before a single clip is merged -- the peds cannot round-trip through Blender at all. (merge_anims.py works because its base, Hum_M_1_mixamo.fbx, is an FBX carrying no such trick -- and that base is exactly why dancer.glb lands 30x small, head bone at 0.0563 m.) So: never rebuild the character. Ship the ped untouched and carry the clips beside it. Bind pose here is deliberately IRRELEVANT: the carrier rig exists only to hold actions. player.js lifts g.animations off this file, canonicalises the bone namespace, and keeps rotation tracks only (quaternions are scale-invariant, so a carrier at any scale retargets cleanly onto the ped). This file's own skeleton is never rendered and its scale is never trusted. """ import bpy, os, re, sys HOME = os.path.expanduser("~") ANIM = f"{HOME}/Documents/3D=models/animations" FBX = f"{HOME}/Documents/FBX" OUT = f"{HOME}/Documents/shades-laneD-out/player_anims.glb" # (clip name in-game, source fbx). These names are the contract with player.js CLIPS. # Locomotion is in-place; the game translates the player itself (PLAN3D 5-D.2). CLIPS = [ ("Idle", f"{ANIM}/Happy Idle.fbx"), ("Walk", f"{ANIM}/Walk.fbx"), ("Run", f"{FBX}/Running.fbx"), ("Falling", f"{FBX}/Falling.fbx"), # limb flail; player.js pitches the root itself ("CrouchToStand", f"{FBX}/Crouch To Stand.fbx"), ("Reaction", f"{FBX}/Reaction.fbx"), # stagger on a debris glance ] PREFIX_RE = re.compile(r"mixamorig\d*:") def imported(fn): """Run an import op; return (new objects, new actions).""" before_o, before_a = set(bpy.data.objects), set(bpy.data.actions) fn() return set(bpy.data.objects) - before_o, list(set(bpy.data.actions) - before_a) # Blender 4.4+ ("slotted actions") moved fcurves from action.fcurves into # action.layers[].strips[].channelbags[].fcurves, and dropped action.fcurves outright by 5.0 # (which is why merge_anims.py no longer runs there as written). Support both layouts. def fcurve_owners(act): """Yield (owning collection, fcurve) so callers can both read and remove.""" if hasattr(act, "fcurves"): for fc in list(act.fcurves): yield act.fcurves, fc return for layer in act.layers: for strip in layer.strips: for bag in strip.channelbags: for fc in list(bag.fcurves): yield bag.fcurves, fc def fcurve_groups(act): if hasattr(act, "groups"): yield from act.groups return for layer in act.layers: for strip in layer.strips: for bag in strip.channelbags: yield from bag.groups bpy.ops.wm.read_factory_settings(use_empty=True) print("\n===BUILD===") carrier = None prefix = None valid = set() actions = [] for name, src in CLIPS: if not os.path.exists(src): print(f" ! MISSING {name:<14} {src}") continue objs, acts = imported(lambda s=src: bpy.ops.import_scene.fbx(filepath=s, automatic_bone_orientation=True)) if not acts: print(f" ! NO ACTION {name:<12} {os.path.basename(src)}") keep = None if carrier is None: # the first clip donates its armature as the carrier; its meshes are dropped keep = next((o for o in objs if o.type == "ARMATURE"), None) if keep is None: sys.exit(f"FATAL: no armature in {src}") carrier = keep m = next((PREFIX_RE.match(b.name) for b in carrier.pose.bones if PREFIX_RE.match(b.name)), None) if not m: sys.exit(f"FATAL: carrier has no mixamorig namespace: {[b.name for b in carrier.pose.bones][:4]}") prefix = m.group(0) valid = {b.name for b in carrier.pose.bones} print(f"carrier : {os.path.basename(src)} armature={carrier.name} " f"bones={len(valid)} namespace={prefix!r}") for act in acts: act.name = name act.use_fake_user = True kept = dropped = 0 for coll, fc in fcurve_owners(act): fc.data_path = PREFIX_RE.sub(prefix, fc.data_path) # retarget onto the carrier namespace bone = re.search(r'pose\.bones\["([^"]+)"\]', fc.data_path) if bone and bone.group(1) not in valid: coll.remove(fc) dropped += 1 else: kept += 1 for g in fcurve_groups(act): g.name = PREFIX_RE.sub(prefix, g.name) actions.append(act) print(f" + {name:<14} {act.frame_range[1] - act.frame_range[0]:6.1f}f fcurves={kept}" + (f" (dropped {dropped} unbindable)" if dropped else "")) for o in objs: # keep the action (and the carrier); bin every mesh and every donor rig if o is not keep: bpy.data.objects.remove(o, do_unlink=True) if not actions: sys.exit("FATAL: no clips merged") # --- one NLA track per action: the glTF exporter emits one animation per track --- if not carrier.animation_data: carrier.animation_data_create() carrier.animation_data.action = None for act in actions: tr = carrier.animation_data.nla_tracks.new() tr.name = act.name strip = tr.strips.new(act.name, int(act.frame_range[0]), act) # Blender 4.4+ slotted actions: a strip with no slot exports as an empty animation if hasattr(strip, "action_slot") and strip.action_slot is None and getattr(act, "slots", None): strip.action_slot = act.slots[0] print(f"NLA tracks: {len(carrier.animation_data.nla_tracks)}") os.makedirs(os.path.dirname(OUT), exist_ok=True) bpy.ops.object.select_all(action="DESELECT") carrier.select_set(True) bpy.ops.export_scene.gltf( filepath=OUT, export_format="GLB", use_selection=True, # carrier only -- no meshes reach the file export_animations=True, export_skins=False, # nothing is skinned to this rig; it is a clip carrier export_animation_mode="NLA_TRACKS", export_apply=False, ) print(f"exported {OUT} ({os.path.getsize(OUT) // 1024} kB)") print("===ENDBUILD===")