# Stage 46: graft AccuRig's skeleton and weights onto the v10 GLB. # # blender --background --python 46_graft.py -- # # The lane's rule (`.agents/plans/rig-graft-lane-2026-08-04.md`): FBX is a disposable rig carrier, # the GLB is the sole source of truth for mesh and materials, and it only ever GAINS bones and # weights. So nothing here touches a vertex position, a UV, or a texture — the mesh that comes out # is byte-for-byte v10's, and that is asserted at the end, not hoped for. # # INDEX-EXACT, not nearest-surface. The plan assumed a decimated bait would have to hand weights to # a full-res mesh by proximity, which is where the July lane mangled the fingers (close-packed # digits confuse a proximity search). AccuRig rigged the shipping mesh whole and returned the same # 31,111 vertices in the same order, so weights copy index-to-index and the failure mode is gone. # # TWO CORRECTIONS, both SOLVED here rather than hardcoded. The carrier was exported at 1.700 m and # AccuRig also shifted the body ~1.2 cm (the same family as the July lane's 4 cm offset trap). Both # are recovered by least-squares fitting a uniform scale + translation between the two vertex sets, # so this stays correct if either changes on a future re-rig. import bpy, sys, os, time import numpy as np from mathutils import Vector, Matrix argv = sys.argv[sys.argv.index("--") + 1:] V10, FBX, OUTB, OUTG = argv[0], argv[1], os.path.abspath(argv[2]), os.path.abspath(argv[3]) t0 = time.time() def log(m): print(f"[graft {time.time()-t0:6.1f}s] {m}", flush=True) bpy.ops.wm.open_mainfile(filepath=V10) body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices)) me = body.data n = len(me.vertices) V = np.empty(n * 3); me.vertices.foreach_get("co", V); V = V.reshape(-1, 3) V_ref = V.copy() log(f"target: '{body.name}' {n}v {len(me.polygons)}f uv={[l.name for l in me.uv_layers]}") before = {o.name for o in bpy.data.objects} bpy.ops.import_scene.fbx(filepath=FBX) new = [o for o in bpy.data.objects if o.name not in before] arm = next(o for o in new if o.type == 'ARMATURE') src = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices)) log(f"carrier: armature '{arm.name}' {len(arm.data.bones)} bones, mesh {len(src.data.vertices)}v") # AccuRig ships a "T-Pose" take, and Blender's importer binds it as an ACTION. An action re-applies # its pose on every evaluation and on every file load, so clearing pose values while it exists is # futile — the in-session assert passes, the saved file comes back posed, and the body deforms # 1.25 units into the air with every edge length unchanged. Drop the animation data first. dropped = [] for o in (arm, src): if o.animation_data: dropped.append(o.name) o.animation_data_clear() dat = o.data if dat and getattr(dat, "animation_data", None): dat.animation_data_clear() sk = getattr(dat, "shape_keys", None) if sk and sk.animation_data: sk.animation_data_clear() for act in list(bpy.data.actions): if act.users == 0: bpy.data.actions.remove(act) log(f"animation data cleared on {dropped}; actions left: {[a.name for a in bpy.data.actions]}") assert len(src.data.vertices) == n, \ f"vertex count differs ({len(src.data.vertices)} vs {n}) — index-exact graft is off the table" # ---- recover scale + translation between carrier and target (order is preserved) ---- S = np.empty(n * 3); src.data.vertices.foreach_get("co", S); S = S.reshape(-1, 3) M = np.array(src.matrix_world) Sw = S @ M[:3, :3].T + M[:3, 3] ca, cb = V.mean(axis=0), Sw.mean(axis=0) A, B = V - ca, Sw - cb s = float((A * B).sum() / (B * B).sum()) t = ca - s * cb res = np.linalg.norm(V - (s * Sw + t), axis=1) UNITM = 1.777 / (V[:, 2].max() - V[:, 2].min()) log(f"fit: scale {s:.6f} (1/{1/s:.6f}) translation {np.round(t, 6)} in target units") log(f" carrier offset removed: {np.round(-t / s * 1000, 3)} mm in carrier space") log(f" index-exact residual: mean {res.mean()*UNITM*1000:.4f} mm p99 " f"{np.percentile(res,99)*UNITM*1000:.4f} mm max {res.max()*UNITM*1000:.4f} mm") # ---- move the skeleton into the target's space; the mesh never moves ---- arm.matrix_world = Matrix.Translation(Vector(t)) @ Matrix.Diagonal( Vector((s, s, s))).to_4x4() @ arm.matrix_world bpy.ops.object.select_all(action='DESELECT') arm.select_set(True) bpy.context.view_layer.objects.active = arm bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) log(f"armature placed; scale now {tuple(round(v,5) for v in arm.scale)}") # Clear the carrier's POSE, and do it AFTER transform_apply. The mesh binds to the REST skeleton, # so any pose riding along is pure displacement — and this FBX arrives with one. Clearing it by # setting location/rotation/scale properties BEFORE the apply did not survive: transform_apply # reintroduced non-identity rotations on the thigh and foot, the saved file deformed the body # 1.25 units into the air, and every edge length stayed put — rigid motion, which reads as # "weights are fine, model is gone". Use the pose operator, last, and assert it took. bpy.ops.object.mode_set(mode='POSE') bpy.ops.pose.select_all(action='SELECT') bpy.ops.pose.transforms_clear() bpy.ops.object.mode_set(mode='OBJECT') bpy.context.view_layer.update() worst_pb = max(np.abs(np.array(pb.matrix_basis) - np.eye(4)).max() for pb in arm.pose.bones) log(f"pose cleared; largest deviation from identity across {len(arm.pose.bones)} bones: " f"{worst_pb:.2e}") assert worst_pb < 1e-6, "pose did not clear — the bind would be displaced" # ---- copy weights index-to-index ---- for vg in list(body.vertex_groups): body.vertex_groups.remove(vg) name_of = {} for g in src.vertex_groups: name_of[g.index] = g.name body.vertex_groups.new(name=g.name) tgt = {g.name: g for g in body.vertex_groups} copied = 0 for v in src.data.vertices: for g in v.groups: w = g.weight if w > 0.0: tgt[name_of[g.group]].add([v.index], w, 'REPLACE') copied += 1 log(f"copied {copied} weights into {len(body.vertex_groups)} groups") # ---- bind ---- for m_ in list(body.modifiers): if m_.type == 'ARMATURE': body.modifiers.remove(m_) mod = body.modifiers.new("Armature", 'ARMATURE') mod.object = arm body.parent = arm body.matrix_parent_inverse = arm.matrix_world.inverted() # ---- the carrier is disposable: nothing visual survives from the FBX ---- carrier_mats = [m_ for m_ in src.data.materials if m_] bpy.data.objects.remove(src, do_unlink=True) for m_ in carrier_mats: if m_.users == 0: bpy.data.materials.remove(m_) for im in list(bpy.data.images): if im.users == 0 and im.name not in {"Render Result", "Viewer Node"}: bpy.data.images.remove(im) # ---- verify ---- V2 = np.empty(n * 3); me.vertices.foreach_get("co", V2); V2 = V2.reshape(-1, 3) moved = np.linalg.norm(V2 - V_ref, axis=1).max() tot = np.zeros(n) for v in me.vertices: tot[v.index] = sum(g.weight for g in v.groups) print("\n=== VERIFY ===") print(f" mesh vertices moved: {moved*UNITM*1000:.6f} mm (must be 0)") print(f" height {V2[:,2].max()-V2[:,2].min():.5f} units, feet min-Z {V2[:,2].min():+.6f}") print(f" weight sums: min {tot.min():.4f} mean {tot.mean():.4f} max {tot.max():.4f}") print(f" unweighted vertices: {int((tot < 1e-6).sum())}") print(f" bones {len(arm.data.bones)} vertex groups {len(body.vertex_groups)}") print(f" uv layers {[l.name for l in me.uv_layers]} materials " f"{[m_.name for m_ in me.materials if m_]}") print(f" images {[i.name for i in bpy.data.images if i.size[0]]}") assert moved < 1e-9, "the mesh moved — the graft must be additive only" # The rest mesh being right proves nothing: the armature modifier is what the engine will run. # Evaluate it and require the deformed body to sit exactly where the undeformed one does. dg = bpy.context.evaluated_depsgraph_get() evo = body.evaluated_get(dg) tmp = evo.to_mesh() Dv = np.empty(len(tmp.vertices) * 3); tmp.vertices.foreach_get("co", Dv) Dv = Dv.reshape(-1, 3) evo.to_mesh_clear() drift = np.linalg.norm(Dv - V_ref, axis=1).max() * UNITM * 1000 print(f" DEFORMED at rest pose: z {Dv[:,2].min():.5f}..{Dv[:,2].max():.5f} " f"max drift vs undeformed {drift:.6f} mm") assert drift < 0.01, (f"the armature displaces the body by {drift:.3f} mm at rest — a leftover " f"pose, not a weighting problem") bpy.ops.wm.save_as_mainfile(filepath=OUTB) bpy.ops.object.select_all(action='DESELECT') arm.select_set(True); body.select_set(True) bpy.context.view_layer.objects.active = arm bpy.ops.export_scene.gltf(filepath=OUTG, export_format='GLB', use_selection=True, export_image_format='AUTO', export_jpeg_quality=95, export_yup=True, export_apply=False, export_skins=True) log(f"EXPORTED {OUTG} ({os.path.getsize(OUTG)/1e6:.2f} MB)") print("GRAFT_DONE")