Ledger #2 asset half (E produces, D wires). Fetched Adobe Mixamo Sitting_Idle.fbx (533KB) by scp from the tailnet source box, converted on-device via Blender (new pipeline/fbx_to_clip.py: import -> skinned GLB export -> pure-python mesh-strip) to web/models/peds/sit.glb (109KB, mesh-free, structurally identical to walk/idle: 66 nodes, 65 mixamorig bones, dangling skin, 195 channels). Verified: three.js loads + 195/195 tracks bind to peds + 0 NaN; Blender confirms seated (hips 0.955->0.299m, stature 1.276->0.758m) + eyes-on bone-dot render (docs/shots/laneE/sit_clip.png). Published to depot via tailnet under John's R13 auth (_published.json 38->39; benign non-procity_ drift note). Provenance: Adobe Mixamo royalty-free, same zone as the shipped walk/idle; LANE_E_NOTES v3.1 amendment marker. -> Lane D: sit.glb ready at web/models/peds/sit.glb (+ on depot); canon binds 195/195; seated confirmed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
6.2 KiB
Python
135 lines
6.2 KiB
Python
"""PROCITY mixamo-clip converter — a Mixamo `.fbx` animation → a mesh-free house clip GLB, the exact
|
|
shape of the shipped `web/models/peds/walk.glb` / `idle.glb`: armature + one animation, NO mesh, but
|
|
WITH the (now dangling) skin that Blender's glTF I/O leaves behind — 65 `mixamorig` joints +
|
|
inverseBindMatrices, referenced by zero nodes. That vestigial skin is what makes glTF tooling (and
|
|
Blender re-import) reconstruct the file as an armature; three.js binds the clip by track name via
|
|
Lane D's `rigs.js` `_canon` regardless, so no bone rename is needed.
|
|
|
|
Runs headless on this box's Blender:
|
|
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
|
"$BL" --background --python fbx_to_clip.py -- IN.fbx OUT.glb [ClipName]
|
|
|
|
Two stages, one command: (1) Blender imports the FBX (mesh+armature+anim) and exports a SKINNED GLB
|
|
(the skin needs the mesh to be emitted); (2) a pure-python glTF strip removes the mesh + its geometry
|
|
accessors, re-indexing/compacting so only the skeleton + dangling skin + animation remain (≈100 KB).
|
|
"""
|
|
import bpy, sys, os, json, struct, tempfile
|
|
|
|
ARGV = sys.argv[sys.argv.index("--") + 1:]
|
|
FBX, OUT = ARGV[0], ARGV[1]
|
|
CLIP = ARGV[2] if len(ARGV) > 2 else "Clip"
|
|
os.makedirs(os.path.dirname(os.path.abspath(OUT)), 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):
|
|
if d.users == 0:
|
|
coll.remove(d)
|
|
|
|
|
|
# ── stage 2: pure-python glTF mesh-strip (keep skeleton + dangling skin + animation) ──────────────
|
|
def read_glb(p):
|
|
d = open(p, "rb").read()
|
|
off = 12; js = None; binblob = b""
|
|
while off < len(d):
|
|
clen, ctype = struct.unpack("<II", d[off:off + 8]); off += 8
|
|
chunk = d[off:off + clen]; off += clen
|
|
if ctype == 0x4E4F534A: js = json.loads(chunk)
|
|
elif ctype == 0x004E4942: binblob = chunk
|
|
return js, binblob
|
|
|
|
|
|
def write_glb(js, binblob, out):
|
|
jb = json.dumps(js, separators=(",", ":")).encode()
|
|
jb += b" " * ((4 - len(jb) % 4) % 4)
|
|
bb = binblob + b"\x00" * ((4 - len(binblob) % 4) % 4)
|
|
total = 12 + 8 + len(jb) + 8 + len(bb)
|
|
with open(out, "wb") as f:
|
|
f.write(b"glTF"); f.write(struct.pack("<II", 2, total))
|
|
f.write(struct.pack("<II", len(jb), 0x4E4F534A)); f.write(jb)
|
|
f.write(struct.pack("<II", len(bb), 0x004E4942)); f.write(bb)
|
|
|
|
|
|
def strip_mesh(inpath, outpath):
|
|
js, binblob = read_glb(inpath)
|
|
keep = set() # accessors to retain: skin IBMs + all anim I/O
|
|
for sk in js.get("skins", []):
|
|
if "inverseBindMatrices" in sk:
|
|
keep.add(sk["inverseBindMatrices"])
|
|
for an in js.get("animations", []):
|
|
for s in an["samplers"]:
|
|
keep.add(s["input"]); keep.add(s["output"])
|
|
mesh_nodes = {i for i, n in enumerate(js["nodes"]) if "mesh" in n}
|
|
js.pop("meshes", None) # drop mesh defs; skins[] stay (become dangling)
|
|
for n in js["nodes"]:
|
|
n.pop("mesh", None); n.pop("skin", None)
|
|
# orphan the former-mesh nodes from the scene graph (leave them in the array so node indices —
|
|
# skin.joints, animation channel targets — stay valid; three.js ignores unreachable nodes)
|
|
for sc in js.get("scenes", []):
|
|
if "nodes" in sc:
|
|
sc["nodes"] = [x for x in sc["nodes"] if x not in mesh_nodes]
|
|
for n in js["nodes"]:
|
|
if "children" in n:
|
|
n["children"] = [x for x in n["children"] if x not in mesh_nodes]
|
|
if not n["children"]:
|
|
n.pop("children")
|
|
# re-index accessors → keep only the retained set
|
|
old_acc = js["accessors"]
|
|
keep_sorted = sorted(keep)
|
|
accmap = {old: i for i, old in enumerate(keep_sorted)}
|
|
js["accessors"] = [old_acc[i] for i in keep_sorted]
|
|
# keep bufferViews referenced by retained accessors; compact the binary
|
|
bv_keep = sorted({old_acc[i]["bufferView"] for i in keep_sorted if "bufferView" in old_acc[i]})
|
|
bvmap = {old: i for i, old in enumerate(bv_keep)}
|
|
old_bv = js["bufferViews"]
|
|
newbin = bytearray(); newbvs = []
|
|
for old in bv_keep:
|
|
bv = dict(old_bv[old]); start = bv.get("byteOffset", 0); length = bv["byteLength"]
|
|
pad = (4 - len(newbin) % 4) % 4; newbin += b"\x00" * pad
|
|
bv["byteOffset"] = len(newbin); newbin += binblob[start:start + length]
|
|
newbvs.append(bv)
|
|
js["bufferViews"] = newbvs
|
|
for a in js["accessors"]:
|
|
if "bufferView" in a:
|
|
a["bufferView"] = bvmap[a["bufferView"]]
|
|
for sk in js.get("skins", []):
|
|
if "inverseBindMatrices" in sk:
|
|
sk["inverseBindMatrices"] = accmap[sk["inverseBindMatrices"]]
|
|
for an in js.get("animations", []):
|
|
for s in an["samplers"]:
|
|
s["input"] = accmap[s["input"]]; s["output"] = accmap[s["output"]]
|
|
js["buffers"] = [{"byteLength": len(newbin)}]
|
|
write_glb(js, bytes(newbin), outpath)
|
|
return js
|
|
|
|
|
|
def main():
|
|
wipe()
|
|
bpy.ops.import_scene.fbx(filepath=FBX)
|
|
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
|
if not arms:
|
|
raise SystemExit("no armature in FBX")
|
|
arm = arms[0]
|
|
n_mesh = len([o for o in bpy.data.objects if o.type == "MESH"])
|
|
if arm.animation_data and arm.animation_data.action:
|
|
arm.animation_data.action.name = CLIP
|
|
bones = [b.name for b in arm.data.bones]
|
|
mixamo = [b for b in bones if b.startswith("mixamorig")]
|
|
bpy.ops.object.select_all(action="SELECT") # armature + mesh → Blender emits a skin
|
|
bpy.context.view_layer.objects.active = arm
|
|
tmp = tempfile.NamedTemporaryFile(suffix=".glb", delete=False).name
|
|
bpy.ops.export_scene.gltf(filepath=tmp, export_format="GLB", use_selection=True,
|
|
export_animations=True, export_yup=True, export_skins=True)
|
|
js = strip_mesh(tmp, OUT)
|
|
os.unlink(tmp)
|
|
sz = os.path.getsize(OUT)
|
|
print(f"CLIP_DONE {os.path.basename(OUT)} bones={len(bones)} (mixamorig {len(mixamo)}) "
|
|
f"mesh_stripped={n_mesh} meshes_out={len(js.get('meshes', []))} skins_out={len(js.get('skins', []))} "
|
|
f"nodes={len(js['nodes'])} anims={len(js.get('animations', []))} {sz // 1024}KB")
|
|
|
|
|
|
main()
|