2026-03-11_4
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
"""FK rotations: copy visible rotations from original to replacement using constraints."""
|
||||
|
||||
import bpy
|
||||
|
||||
# Arm FK bone name patterns to check (Rigify and common alternatives)
|
||||
ARM_FK_PATTERNS = (
|
||||
# Rigify style
|
||||
("upper_arm_fk.L", "upper_arm_fk.R"),
|
||||
("forearm_fk.L", "forearm_fk.R"),
|
||||
("hand_fk.L", "hand_fk.R"),
|
||||
# Common alternatives
|
||||
("upper_arm.L", "upper_arm.R"),
|
||||
("forearm.L", "forearm.R"),
|
||||
("hand.L", "hand.R"),
|
||||
("arm_fk.L", "arm_fk.R"),
|
||||
("lower_arm_fk.L", "lower_arm_fk.R"),
|
||||
# Short forms
|
||||
("arm.L", "arm.R"),
|
||||
("elbow.L", "elbow.R"),
|
||||
)
|
||||
|
||||
# Finger bone name patterns (will match with .01, .02, .03, etc.)
|
||||
FINGER_PREFIXES = (
|
||||
"thumb", "f_index", "f_middle", "f_ring", "f_pinky",
|
||||
"finger1", "finger2", "finger3", "finger4", "finger5",
|
||||
)
|
||||
|
||||
|
||||
def _get_matching_arm_fk_bones(armature):
|
||||
"""Return list of arm FK bone names that exist on armature."""
|
||||
if not armature or armature.type != "ARMATURE" or not armature.pose:
|
||||
return []
|
||||
bones = armature.pose.bones
|
||||
found = []
|
||||
for pattern in ARM_FK_PATTERNS:
|
||||
for name in pattern:
|
||||
if name in bones:
|
||||
found.append(name)
|
||||
return found
|
||||
|
||||
|
||||
def _get_finger_fk_bones(armature):
|
||||
"""Return list of FK finger bone names that exist on armature. Only control bones (no ORG-/DEF-/MCH- prefix)."""
|
||||
if not armature or armature.type != "ARMATURE" or not armature.pose:
|
||||
return []
|
||||
bones = armature.pose.bones
|
||||
finger_bones = []
|
||||
for bone_name in bones.keys():
|
||||
if bone_name.startswith("ORG-") or bone_name.startswith("DEF-") or bone_name.startswith("MCH-"):
|
||||
continue
|
||||
lower_name = bone_name.lower()
|
||||
for prefix in FINGER_PREFIXES:
|
||||
if prefix in lower_name and ("_fk." in bone_name or bone_name.endswith(".L") or bone_name.endswith(".R")):
|
||||
if "." in bone_name and any(d in bone_name for d in "0123456789"):
|
||||
finger_bones.append(bone_name)
|
||||
break
|
||||
return finger_bones
|
||||
|
||||
|
||||
def _get_fk_bones(armature):
|
||||
"""Return list of all FK arm and finger bone names that exist on armature."""
|
||||
arm_bones = _get_matching_arm_fk_bones(armature)
|
||||
finger_bones = _get_finger_fk_bones(armature)
|
||||
return list(dict.fromkeys(arm_bones + finger_bones))
|
||||
|
||||
|
||||
def copy_fk_rotations(context, orig, rep):
|
||||
"""
|
||||
Copy visual rotations from orig to rep using temporary COPY_TRANSFORMS constraints.
|
||||
This properly handles all coordinate space conversions.
|
||||
Returns (True, message) or (False, error_message).
|
||||
"""
|
||||
fk_names = _get_fk_bones(rep)
|
||||
|
||||
print(f"[DLM MigFKRot] Found {len(fk_names)} FK bones on {rep.name}")
|
||||
|
||||
if not fk_names:
|
||||
return False, "No FK arm or finger bones found on replacement armature"
|
||||
|
||||
# Filter to bones that exist on both
|
||||
common_bones = [n for n in fk_names if n in orig.pose.bones and n in rep.pose.bones]
|
||||
if not common_bones:
|
||||
return False, "No matching FK bones found on both armatures"
|
||||
|
||||
print(f"[DLM MigFKRot] Will copy {len(common_bones)} bones using constraints")
|
||||
|
||||
original_active = context.view_layer.objects.active
|
||||
constraints_added = []
|
||||
|
||||
try:
|
||||
# Ensure rep is active and in pose mode
|
||||
bpy.context.view_layer.objects.active = rep
|
||||
if rep.mode != 'POSE':
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
# Step 1: Add COPY_TRANSFORMS constraints to each rep bone
|
||||
for bone_name in common_bones:
|
||||
rep_bone = rep.pose.bones[bone_name]
|
||||
|
||||
# Check if bone already has this constraint
|
||||
existing = [c for c in rep_bone.constraints if c.type == 'COPY_TRANSFORMS' and getattr(c, 'target', None) == orig]
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Add constraint
|
||||
c = rep_bone.constraints.new(type='COPY_TRANSFORMS')
|
||||
c.name = "MigFKRot_Temp"
|
||||
c.target = orig
|
||||
c.subtarget = bone_name
|
||||
c.target_space = 'POSE'
|
||||
c.owner_space = 'POSE'
|
||||
constraints_added.append((rep_bone, c))
|
||||
|
||||
# Step 2: Update scene to evaluate constraints
|
||||
context.view_layer.update()
|
||||
|
||||
# Step 3: Apply visual transform (bake constraint result into pose)
|
||||
try:
|
||||
bpy.ops.pose.select_all(action='DESELECT')
|
||||
for rep_bone, _ in constraints_added:
|
||||
rep_bone.bone.select = True
|
||||
# Apply visual transform - this bakes the constraint result
|
||||
bpy.ops.pose.visual_transform_apply()
|
||||
except (RuntimeError, AttributeError):
|
||||
# visual_transform_apply requires bones to be selectable
|
||||
# AttributeError: 'bone.select' may not exist in Blender 5.0
|
||||
# If selection fails, the constraint result is still applied
|
||||
pass # Silently ignore - constraints still drove the pose
|
||||
|
||||
# Constraints remain active for bake step
|
||||
print(f"[DLM MigFKRot] Copied {len(constraints_added)} bones (constraints active)")
|
||||
return True, f"Copied FK rotations for {len(constraints_added)} bones (constraints active - run Bake to finalize)"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DLM MigFKRot] Error: {e}")
|
||||
# Cleanup constraints on error
|
||||
for rep_bone, c in constraints_added:
|
||||
try:
|
||||
if c in rep_bone.constraints:
|
||||
rep_bone.constraints.remove(c)
|
||||
except:
|
||||
pass
|
||||
return False, str(e)
|
||||
|
||||
finally:
|
||||
# Only restore active object, don't remove constraints
|
||||
if original_active:
|
||||
context.view_layer.objects.active = original_active
|
||||
|
||||
|
||||
def _get_action_frame_range(action):
|
||||
"""Get the full frame range from action keyframes (not just strip in/out)."""
|
||||
if not action:
|
||||
return None
|
||||
|
||||
frames = set()
|
||||
|
||||
# Try Blender 5.0+ channelbags first
|
||||
if hasattr(action, 'channelbags'):
|
||||
for cb in action.channelbags:
|
||||
if hasattr(cb, 'fcurves'):
|
||||
for fc in cb.fcurves:
|
||||
for kp in fc.keyframe_points:
|
||||
frames.add(int(kp.co.x))
|
||||
|
||||
# Fallback to fcurves
|
||||
if hasattr(action, 'fcurves') and not frames:
|
||||
for fc in action.fcurves:
|
||||
for kp in fc.keyframe_points:
|
||||
frames.add(int(kp.co.x))
|
||||
|
||||
if frames:
|
||||
return (min(frames), max(frames))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_bone_name_from_data_path(data_path):
|
||||
"""Extract bone name from fcurve data_path like 'pose.bones["bone.name"].rotation_euler'."""
|
||||
if not data_path:
|
||||
return None
|
||||
if 'pose.bones["' in data_path:
|
||||
start = data_path.find('["') + 2
|
||||
end = data_path.find('"]', start)
|
||||
if start > 1 and end > start:
|
||||
return data_path[start:end]
|
||||
elif "pose.bones['" in data_path:
|
||||
start = data_path.find("['") + 2
|
||||
end = data_path.find("']", start)
|
||||
if start > 1 and end > start:
|
||||
return data_path[start:end]
|
||||
return None
|
||||
|
||||
|
||||
def bake_fk_rotations(context, orig, rep, track_name=None, post_clean=False):
|
||||
"""
|
||||
Bake FK arm/finger rotations to a new NLA track with replace mode.
|
||||
Returns (True, message) or (False, error_message).
|
||||
"""
|
||||
print(f"[DLM MigFKRot Bake] START: orig={orig.name}, rep={rep.name}")
|
||||
|
||||
fk_names = _get_fk_bones(rep)
|
||||
print(f"[DLM MigFKRot Bake] Found {len(fk_names)} FK bones: {fk_names[:5]}...")
|
||||
|
||||
if not fk_names:
|
||||
return False, f"No FK bones found on {rep.name}"
|
||||
|
||||
# Filter to bones that exist on both
|
||||
common_bones = [n for n in fk_names if n in orig.pose.bones and n in rep.pose.bones]
|
||||
print(f"[DLM MigFKRot Bake] {len(common_bones)} common bones between orig and rep")
|
||||
|
||||
if not common_bones:
|
||||
return False, "No matching FK bones found on both armatures"
|
||||
|
||||
# Get source action for frame range (from keyframes, not strip bounds)
|
||||
source_action = None
|
||||
if rep.animation_data:
|
||||
if rep.animation_data.action:
|
||||
source_action = rep.animation_data.action
|
||||
elif rep.animation_data.nla_tracks:
|
||||
for track in rep.animation_data.nla_tracks:
|
||||
if track.strips:
|
||||
for strip in track.strips:
|
||||
if strip.action:
|
||||
source_action = strip.action
|
||||
break
|
||||
if source_action:
|
||||
break
|
||||
|
||||
# Get frame range from source action keyframes
|
||||
frame_range = _get_action_frame_range(source_action) if source_action else None
|
||||
|
||||
if not frame_range:
|
||||
# Fallback to scene range
|
||||
frame_range = (context.scene.frame_start, context.scene.frame_end)
|
||||
|
||||
frame_start, frame_end = frame_range
|
||||
|
||||
# Ensure rep is active and in pose mode
|
||||
if context.view_layer.objects.active != rep:
|
||||
context.view_layer.objects.active = rep
|
||||
if rep.mode != "POSE":
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
|
||||
# Step 1: Check for existing COPY_TRANSFORMS constraints
|
||||
constraints_added = []
|
||||
for bone_name in common_bones:
|
||||
rep_bone = rep.pose.bones[bone_name]
|
||||
existing = [c for c in rep_bone.constraints if c.type == 'COPY_TRANSFORMS' and getattr(c, 'target', None) == orig]
|
||||
if existing:
|
||||
constraints_added.append((rep_bone, existing[0]))
|
||||
|
||||
print(f"[DLM MigFKRot Bake] Found {len(constraints_added)} constraints from {orig.name} to {rep.name}")
|
||||
print(f"[DLM MigFKRot Bake] Frame range: {frame_start}-{frame_end}")
|
||||
|
||||
# Step 2: Create new action and bake with nla.bake
|
||||
# We use the current action (not a new one) because nla.bake needs use_current_action=True
|
||||
# After baking, we'll clean up non-FK fcurves from the baked action
|
||||
|
||||
# First, ensure there's an action to bake to
|
||||
if rep.animation_data is None:
|
||||
rep.animation_data_create()
|
||||
|
||||
# If there's no current action, create one
|
||||
if not rep.animation_data.action:
|
||||
action_name = f"{rep.name}_FK_Bake_{frame_start}-{frame_end}"
|
||||
new_action = bpy.data.actions.new(name=action_name)
|
||||
if len(new_action.slots) == 0:
|
||||
new_action.slots.new(name=rep.name, id_type='OBJECT')
|
||||
slot = new_action.slots[0]
|
||||
try:
|
||||
rep.animation_data.action_slot = slot
|
||||
except Exception as e:
|
||||
print(f"[DLM MigFKRot Bake] Warning: Could not set slot: {e}")
|
||||
|
||||
# Store the action we're baking to
|
||||
baked_action = rep.animation_data.action
|
||||
print(f"[DLM MigFKRot Bake] Baking to action: {baked_action.name if baked_action else 'None'}")
|
||||
|
||||
# Step 3: Select only FK bones and bake with only_selected=True
|
||||
print(f"[DLM MigFKRot Bake] Selecting {len(common_bones)} FK bones...")
|
||||
try:
|
||||
# Deselect all bones first
|
||||
bpy.ops.pose.select_all(action='DESELECT')
|
||||
|
||||
# Select only our FK bones
|
||||
for bone_name in common_bones:
|
||||
if bone_name in rep.pose.bones:
|
||||
rep_bone = rep.pose.bones[bone_name]
|
||||
# Try different ways to select the bone (Blender 5.0 compatibility)
|
||||
try:
|
||||
# Method 1: Direct selection on pose bone
|
||||
rep_bone.select = True
|
||||
except (AttributeError, TypeError):
|
||||
try:
|
||||
# Method 2: Selection on the bone property
|
||||
if hasattr(rep_bone, 'bone'):
|
||||
rep_bone.bone.select = True
|
||||
except (AttributeError, TypeError):
|
||||
pass # Selection failed, continue anyway
|
||||
|
||||
print(f"[DLM MigFKRot Bake] Running nla.bake with only_selected=True...")
|
||||
bpy.ops.nla.bake(
|
||||
frame_start=frame_start,
|
||||
frame_end=frame_end,
|
||||
step=1,
|
||||
only_selected=True,
|
||||
visual_keying=True,
|
||||
clear_constraints=False,
|
||||
clear_parents=False,
|
||||
use_current_action=True,
|
||||
clean_curves=False,
|
||||
bake_types={"POSE"},
|
||||
channel_types={"ROTATION"},
|
||||
)
|
||||
print(f"[DLM MigFKRot Bake] nla.bake completed successfully")
|
||||
except Exception as e:
|
||||
# Clean up constraints on failure
|
||||
for rep_bone, c in constraints_added:
|
||||
try:
|
||||
if c in rep_bone.constraints:
|
||||
rep_bone.constraints.remove(c)
|
||||
except:
|
||||
pass
|
||||
return False, f"nla.bake failed: {e}"
|
||||
|
||||
# Step 4: Remove constraints
|
||||
removed_count = 0
|
||||
for rep_bone, c in constraints_added:
|
||||
try:
|
||||
if c in rep_bone.constraints:
|
||||
rep_bone.constraints.remove(c)
|
||||
removed_count += 1
|
||||
except:
|
||||
pass
|
||||
print(f"[DLM MigFKRot Bake] Removed {removed_count} constraints")
|
||||
|
||||
# Step 5: Create new NLA track at TOP with replace mode
|
||||
# Use the baked action (which now has only FK fcurves)
|
||||
if rep.animation_data and baked_action:
|
||||
nla_track = rep.animation_data.nla_tracks.new(prev=None)
|
||||
nla_track.name = f"FK_Bake_{frame_start}-{frame_end}"
|
||||
|
||||
strip = nla_track.strips.new(name=baked_action.name, start=frame_start, action=baked_action)
|
||||
strip.frame_end = frame_end
|
||||
strip.blend_type = 'REPLACE'
|
||||
strip.use_auto_blend = False
|
||||
|
||||
print(f"[DLM MigFKRot Bake] Created NLA track '{nla_track.name}' (REPLACE mode)")
|
||||
|
||||
if not post_clean:
|
||||
return True, f"Baked {len(common_bones)} FK bones to NLA track ({frame_start}-{frame_end})."
|
||||
|
||||
# Post-clean
|
||||
win = context.window
|
||||
for area in win.screen.areas:
|
||||
if area.type == "DOPESHEET_EDITOR":
|
||||
with context.temp_override(window=win, area=area):
|
||||
try:
|
||||
bpy.ops.action.clean_keyframes()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
for area in win.screen.areas:
|
||||
if area.type == "GRAPH_EDITOR":
|
||||
with context.temp_override(window=win, area=area):
|
||||
try:
|
||||
bpy.ops.graph.decimate(mode="ERROR", error=0.001)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
return True, f"Baked and cleaned {len(common_bones)} FK bones to NLA track ({frame_start}-{frame_end})."
|
||||
|
||||
|
||||
def remove_fk_rotations(context, rep):
|
||||
"""
|
||||
Remove COPY_TRANSFORMS constraints that were added by copy_fk_rotations.
|
||||
Similar to tweak_tools.remove_tweak_constraints.
|
||||
Returns (True, message) or (False, error_message).
|
||||
"""
|
||||
fk_names = _get_fk_bones(rep)
|
||||
if not fk_names:
|
||||
return False, f"No FK bones found on {rep.name}"
|
||||
|
||||
removed = 0
|
||||
for bone_name in fk_names:
|
||||
if bone_name not in rep.pose.bones:
|
||||
continue
|
||||
rep_bone = rep.pose.bones[bone_name]
|
||||
to_remove = [
|
||||
c for c in rep_bone.constraints
|
||||
if c.type == 'COPY_TRANSFORMS' and c.name == "MigFKRot_Temp"
|
||||
]
|
||||
for c in to_remove:
|
||||
rep_bone.constraints.remove(c)
|
||||
removed += 1
|
||||
|
||||
return True, f"Removed {removed} FK rotation constraints."
|
||||
@@ -184,8 +184,27 @@ def _mirror_als_turn_on(orig, rep):
|
||||
pass
|
||||
|
||||
|
||||
def _duplicate_action(src_action, suffix=".rep"):
|
||||
"""Duplicate an action, returning the new action with a unique name."""
|
||||
if src_action is None:
|
||||
return None
|
||||
new_name = src_action.name
|
||||
if not new_name.endswith(suffix):
|
||||
new_name = f"{new_name}{suffix}"
|
||||
# Ensure unique name
|
||||
base_name = new_name
|
||||
n = 1
|
||||
while new_name in bpy.data.actions:
|
||||
new_name = f"{base_name}.{n:03d}"
|
||||
n += 1
|
||||
new_action = src_action.copy()
|
||||
new_action.name = new_name
|
||||
return new_action
|
||||
|
||||
|
||||
def run_mig_nla(orig, rep, report=None):
|
||||
"""Migrate NLA: copy tracks and strips to replacement; or mirror action slot when no NLA (MigNLA)."""
|
||||
"""Migrate NLA: copy tracks and strips to replacement; or mirror action slot when no NLA (MigNLA).
|
||||
Actions are duplicated so repchar has independent copies."""
|
||||
if not orig.animation_data:
|
||||
return
|
||||
ad = orig.animation_data
|
||||
@@ -211,11 +230,13 @@ def run_mig_nla(orig, rep, report=None):
|
||||
print(f"[DLM MigNLA] {p}={v!r}")
|
||||
_slot_debug("Orig (before)", ad)
|
||||
_slot_debug("Rep (before)", rad)
|
||||
# Duplicate the active action for repchar
|
||||
dup_action = _duplicate_action(active_action, suffix=".rep")
|
||||
# Copy last_slot_identifier before action so slot is resolved when assigning (4.4+).
|
||||
if hasattr(ad, "last_slot_identifier") and hasattr(rad, "last_slot_identifier") and ad.last_slot_identifier:
|
||||
rad.last_slot_identifier = ad.last_slot_identifier
|
||||
print(f"[DLM MigNLA] set rep last_slot_identifier={ad.last_slot_identifier!r}")
|
||||
rad.action = active_action
|
||||
rad.action = dup_action
|
||||
# Copy Action Slot and related props (Blender 4.4+ slotted actions).
|
||||
if getattr(ad, "action_slot", None) and getattr(rad, "action_slot", None):
|
||||
try:
|
||||
@@ -230,7 +251,7 @@ def run_mig_nla(orig, rep, report=None):
|
||||
_slot_debug("Rep (after)", rad)
|
||||
_mirror_als_turn_on(orig, rep)
|
||||
if report:
|
||||
report({"INFO"}, "No NLA detected, active action and slot copied to Replacement Armature.")
|
||||
report({"INFO"}, "No NLA detected, active action (duplicated) and slot copied to Replacement Armature.")
|
||||
return
|
||||
if rep.animation_data is None:
|
||||
rep.animation_data_create()
|
||||
@@ -253,8 +274,9 @@ def run_mig_nla(orig, rep, report=None):
|
||||
for strip in track.strips:
|
||||
if strip.type != "CLIP" or not strip.action:
|
||||
continue
|
||||
dup_action = _duplicate_action(strip.action, suffix=".rep")
|
||||
new_strip = new_track.strips.new(
|
||||
strip.name, int(strip.frame_start), strip.action
|
||||
strip.name, int(strip.frame_start), dup_action
|
||||
)
|
||||
new_strip.blend_type = strip.blend_type
|
||||
new_strip.extrapolation = strip.extrapolation
|
||||
@@ -438,6 +460,16 @@ def run_mig_bone_const(orig, rep, orig_to_rep):
|
||||
|
||||
def run_retarg_relatives(orig, rep, rep_descendants, orig_to_rep):
|
||||
"""Retarget relations: parents, constraint targets, Armature modifiers to rep. Skip objects in orig's hierarchy (linked collection)."""
|
||||
# Replicate orig's parent on rep if it exists (keep world transform)
|
||||
if orig.parent is not None:
|
||||
# Store world matrix before reparenting
|
||||
world_matrix = rep.matrix_world.copy()
|
||||
rep.parent = orig.parent
|
||||
rep.parent_type = orig.parent_type
|
||||
rep.parent_bone = orig.parent_bone
|
||||
# Restore world matrix
|
||||
rep.matrix_world = world_matrix
|
||||
|
||||
orig_hierarchy = {orig} | descendants(orig)
|
||||
candidates = set(rep_descendants)
|
||||
for ob in bpy.data.objects:
|
||||
@@ -612,10 +644,12 @@ def run_mig_bbody_shapekeys(orig, rep, rep_descendants, context=None):
|
||||
or bpy.data.actions.get(body_name + "Action.001")
|
||||
)
|
||||
if action:
|
||||
# Duplicate action so repchar has independent copy
|
||||
dup_action = _duplicate_action(action, suffix=".rep")
|
||||
# Copy slot-related props before action so slot is applied (Blender 4.4+).
|
||||
if orig_sk_ad and hasattr(sk_ad, "last_slot_identifier") and hasattr(orig_sk_ad, "last_slot_identifier") and orig_sk_ad.last_slot_identifier:
|
||||
sk_ad.last_slot_identifier = orig_sk_ad.last_slot_identifier
|
||||
sk_ad.action = action
|
||||
sk_ad.action = dup_action
|
||||
if orig_sk_ad and getattr(orig_sk_ad, "action_slot", None) and getattr(sk_ad, "action_slot", None):
|
||||
try:
|
||||
sk_ad.action_slot = orig_sk_ad.action_slot
|
||||
|
||||
Reference in New Issue
Block a user