2026-03-11_4
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"last_check": "2026-03-05 13:07:15.606509",
|
||||
"last_check": "2026-03-06 10:02:06.473451",
|
||||
"backup_date": "January-12-2026",
|
||||
"update_ready": false,
|
||||
"ignore": false,
|
||||
|
||||
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
|
||||
id = "dynamiclinkmanager"
|
||||
name = "Dynamic Link Manager"
|
||||
tagline = "Character migrator and linked library tools"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
type = "add-on"
|
||||
|
||||
# Optional: Semantic Versioning
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
import bpy
|
||||
import os
|
||||
from bpy.types import Operator
|
||||
from bpy.props import StringProperty, BoolProperty
|
||||
from bpy.props import StringProperty, IntProperty
|
||||
|
||||
from ..utils import collection_containing_armature
|
||||
|
||||
ADDON_NAME = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
|
||||
|
||||
|
||||
@@ -403,40 +406,174 @@ class DLM_OT_migrator_basebody_shapekeys(Operator):
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
MIGRATOR_STEP_OPS = (
|
||||
"dlm.migrator_copy_attributes",
|
||||
"dlm.migrator_migrate_nla",
|
||||
"dlm.migrator_custom_properties",
|
||||
"dlm.migrator_bone_constraints",
|
||||
"dlm.migrator_retarget_relations",
|
||||
"dlm.migrator_basebody_shapekeys",
|
||||
)
|
||||
|
||||
|
||||
class DLM_OT_run_character_migration(Operator):
|
||||
bl_idname = "dlm.run_character_migration"
|
||||
bl_label = "Run Character Migration"
|
||||
bl_description = "Run all six migration steps (CopyAttr, MigNLA, MigCustProps, MigBoneConst, RetargRelatives, MigBBodyShapeKeys) in order"
|
||||
class DLM_OT_migrator_fk_rotations(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations"
|
||||
bl_label = "MigFKRot"
|
||||
bl_description = "Copy FK arm and finger rotations from original to replacement (uses constraints)"
|
||||
bl_icon = "BONE_DATA"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
steps = [
|
||||
bpy.ops.dlm.migrator_copy_attributes,
|
||||
bpy.ops.dlm.migrator_migrate_nla,
|
||||
bpy.ops.dlm.migrator_custom_properties,
|
||||
bpy.ops.dlm.migrator_bone_constraints,
|
||||
bpy.ops.dlm.migrator_retarget_relations,
|
||||
bpy.ops.dlm.migrator_basebody_shapekeys,
|
||||
]
|
||||
for i, op in enumerate(steps):
|
||||
result = op()
|
||||
if result != {"FINISHED"}:
|
||||
self.report({"ERROR"}, f"Migration failed at step {i + 1}: {MIGRATOR_STEP_OPS[i]}")
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import copy_fk_rotations
|
||||
ok, msg = copy_fk_rotations(context, orig, rep)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
self.report({"INFO"}, "Migration complete.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations_bake(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations_bake"
|
||||
bl_label = "Bake MigFKRot"
|
||||
bl_description = "Bake FK rotations to keyframes using nla.bake (similar to tweak tools)"
|
||||
bl_icon = "KEYFRAME"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
track_name: StringProperty(name="NLA Track", description="Optional NLA track name for frame range", default="")
|
||||
post_clean: BoolProperty(name="Post-clean", description="Clean curves after bake", default=False)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import bake_fk_rotations
|
||||
ok, msg = bake_fk_rotations(context, orig, rep, track_name=self.track_name or None, post_clean=self.post_clean)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations_remove(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations_remove"
|
||||
bl_label = "Remove MigFKRot"
|
||||
bl_description = "Remove FK rotation COPY_TRANSFORMS constraints (similar to tweak_remove_arm)"
|
||||
bl_icon = "X"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import remove_fk_rotations
|
||||
ok, msg = remove_fk_rotations(context, rep)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
|
||||
|
||||
class DLM_OT_migrator_remove_original(Operator):
|
||||
bl_idname = "dlm.migrator_remove_original"
|
||||
bl_label = "Remove Original"
|
||||
bl_description = "Delete the original character armature and its data from the scene"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig:
|
||||
self.report({"WARNING"}, "No original character selected")
|
||||
return {"CANCELLED"}
|
||||
if orig == rep:
|
||||
self.report({"ERROR"}, "Original and replacement cannot be the same object")
|
||||
return {"CANCELLED"}
|
||||
|
||||
name = orig.name
|
||||
|
||||
# Collect actions from original character before removal
|
||||
actions_to_remove = set()
|
||||
if orig.animation_data:
|
||||
# Active action
|
||||
if orig.animation_data.action:
|
||||
actions_to_remove.add(orig.animation_data.action)
|
||||
# NLA strips
|
||||
for track in orig.animation_data.nla_tracks:
|
||||
for strip in track.strips:
|
||||
if strip.action:
|
||||
actions_to_remove.add(strip.action)
|
||||
|
||||
# Remove collected actions from bpy.data.actions
|
||||
removed_actions = []
|
||||
for action in actions_to_remove:
|
||||
action_name = action.name
|
||||
try:
|
||||
bpy.data.actions.remove(action)
|
||||
removed_actions.append(action_name)
|
||||
except Exception as e:
|
||||
self.report({"WARNING"}, f"Could not remove action {action_name}: {e}")
|
||||
|
||||
if removed_actions:
|
||||
self.report({"INFO"}, f"Removed {len(removed_actions)} action(s) from original")
|
||||
|
||||
try:
|
||||
# Try to find and delete the collection containing the original character
|
||||
coll = collection_containing_armature(orig)
|
||||
if coll:
|
||||
coll_name = coll.name # Store name BEFORE removal (RNA invalidates after remove)
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
try:
|
||||
bpy.data.collections.remove(coll)
|
||||
self.report({"INFO"}, f"Removed collection: {coll_name}")
|
||||
except Exception as remove_err:
|
||||
# Collection may have already been removed by another process
|
||||
self.report({"WARNING"}, f"Collection {coll_name} removal issue: {remove_err}")
|
||||
else:
|
||||
# Fallback: just delete the armature object
|
||||
bpy.data.objects.remove(orig, do_unlink=True)
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
self.report({"INFO"}, f"Removed original character: {name}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to remove original: {e}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Rename replacement actions with ".rep" suffix
|
||||
if rep and rep.animation_data:
|
||||
renamed_actions = []
|
||||
# Active action
|
||||
if rep.animation_data.action and ".rep" in rep.animation_data.action.name:
|
||||
old_name = rep.animation_data.action.name
|
||||
new_name = old_name.replace(".rep", "")
|
||||
rep.animation_data.action.name = new_name
|
||||
renamed_actions.append(f"{old_name} -> {new_name}")
|
||||
# NLA strips
|
||||
for track in rep.animation_data.nla_tracks:
|
||||
for strip in track.strips:
|
||||
if strip.action and ".rep" in strip.action.name:
|
||||
old_name = strip.action.name
|
||||
new_name = old_name.replace(".rep", "")
|
||||
strip.action.name = new_name
|
||||
renamed_actions.append(f"{old_name} -> {new_name}")
|
||||
if renamed_actions:
|
||||
self.report({"INFO"}, f"Renamed {len(renamed_actions)} replacement action(s)")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
class DLM_OT_picker_original_character(Operator):
|
||||
bl_idname = "dlm.picker_original_character"
|
||||
bl_label = "Pick Original"
|
||||
@@ -673,7 +810,7 @@ OPERATOR_CLASSES = [
|
||||
DLM_OT_make_paths_relative,
|
||||
DLM_OT_make_paths_absolute,
|
||||
DLM_OT_relocate_single_library,
|
||||
DLM_OT_run_character_migration,
|
||||
DLM_OT_migrator_remove_original,
|
||||
DLM_OT_picker_original_character,
|
||||
DLM_OT_picker_replacement_character,
|
||||
DLM_OT_migrator_copy_attributes,
|
||||
@@ -691,4 +828,7 @@ OPERATOR_CLASSES = [
|
||||
DLM_OT_tweak_add_both,
|
||||
DLM_OT_tweak_remove_both,
|
||||
DLM_OT_tweak_bake_both,
|
||||
DLM_OT_migrator_fk_rotations,
|
||||
DLM_OT_migrator_fk_rotations_bake,
|
||||
DLM_OT_migrator_fk_rotations_remove,
|
||||
]
|
||||
|
||||
@@ -84,7 +84,7 @@ class DLM_PT_main_panel(Panel):
|
||||
row.prop(props, "replacement_character", text="Replacement")
|
||||
row.operator("dlm.picker_replacement_character", text="", icon="EYEDROPPER")
|
||||
row = box.row()
|
||||
row.operator("dlm.run_character_migration", text="Run migration", icon="ARMATURE_DATA")
|
||||
row.operator("dlm.migrator_remove_original", text="Remove Original", icon="TRASH")
|
||||
row = box.row(align=True)
|
||||
row.operator("dlm.migrator_copy_attributes", text="CopyAttr", icon="COPY_ID")
|
||||
row.operator("dlm.migrator_migrate_nla", text="MigNLA", icon="NLA")
|
||||
@@ -92,27 +92,40 @@ class DLM_PT_main_panel(Panel):
|
||||
row = box.row(align=True)
|
||||
row.operator("dlm.migrator_bone_constraints", text="MigBoneConst", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.migrator_retarget_relations", text="RetargRelatives", icon="ORIENTATION_PARENT")
|
||||
row.operator("dlm.migrator_basebody_shapekeys", text="MigBBodyShapeKeys", icon="SHAPEKEY_DATA")
|
||||
|
||||
# Tweak Tools
|
||||
tweak_box = layout.box()
|
||||
tweak_box.label(text="Tweak Tools", icon="CONSTRAINT")
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_arm", text="Add Arm", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_arm", text="Remove Arm", icon="X")
|
||||
row.operator("dlm.tweak_bake_arm", text="Bake Arm", icon="KEYFRAME")
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_leg", text="Add Leg", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_leg", text="Remove Leg", icon="X")
|
||||
row.operator("dlm.tweak_bake_leg", text="Bake Leg", icon="KEYFRAME")
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_both", text="Add Both", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_both", text="Remove Both", icon="X")
|
||||
row.operator("dlm.tweak_bake_both", text="Bake Both", icon="KEYFRAME")
|
||||
row = tweak_box.row()
|
||||
row.prop(props, "tweak_nla_track_name", text="NLA track")
|
||||
row = tweak_box.row()
|
||||
row.prop(props, "tweak_bake_post_clean", text="Post-clean after bake")
|
||||
# Situational
|
||||
situational_box = layout.box()
|
||||
situational_box.label(text="Situational Fixes", icon="QUESTION")
|
||||
row = situational_box.row(align=True)
|
||||
row.operator("dlm.migrator_basebody_shapekeys", text="MigBBodyShapeKeys", icon="SHAPEKEY_DATA")
|
||||
row = situational_box.row(align=True)
|
||||
row.operator("dlm.migrator_fk_rotations", text="MigFKRot", icon="BONE_DATA")
|
||||
row.operator("dlm.migrator_fk_rotations_remove", text="Remove", icon="X")
|
||||
row.operator("dlm.migrator_fk_rotations_bake", text="Bake", icon="KEYFRAME")
|
||||
|
||||
# Tweak Tools: header row (always), main box only when expanded
|
||||
section_icon = "DISCLOSURE_TRI_DOWN" if props.tweak_tools_section_expanded else "DISCLOSURE_TRI_RIGHT"
|
||||
row = layout.row(align=True)
|
||||
row.prop(props, "tweak_tools_section_expanded", text="", icon=section_icon, icon_only=True)
|
||||
row.label(text="Tweak Tools", icon="CONSTRAINT")
|
||||
if props.tweak_tools_section_expanded:
|
||||
tweak_box = layout.box()
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_arm", text="Add Arm", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_arm", text="Remove Arm", icon="X")
|
||||
row.operator("dlm.tweak_bake_arm", text="Bake Arm", icon="KEYFRAME")
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_leg", text="Add Leg", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_leg", text="Remove Leg", icon="X")
|
||||
row.operator("dlm.tweak_bake_leg", text="Bake Leg", icon="KEYFRAME")
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_both", text="Add Both", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_both", text="Remove Both", icon="X")
|
||||
row.operator("dlm.tweak_bake_both", text="Bake Both", icon="KEYFRAME")
|
||||
row = tweak_box.row()
|
||||
row.prop(props, "tweak_nla_track_name", text="NLA track")
|
||||
row = tweak_box.row()
|
||||
row.prop(props, "tweak_bake_post_clean", text="Post-clean after bake")
|
||||
|
||||
# Linked Libraries: header row (always), main box only when expanded
|
||||
missing_count = sum(1 for lib in props.linked_libraries if lib.is_missing)
|
||||
|
||||
@@ -60,7 +60,12 @@ class DynamicLinkManagerProperties(PropertyGroup):
|
||||
poll=lambda self, obj: obj and obj.type == "ARMATURE",
|
||||
)
|
||||
|
||||
# Tweak tools (bake frame range and post-clean)
|
||||
# Tweak tools (collapsible section)
|
||||
tweak_tools_section_expanded: BoolProperty(
|
||||
name="Tweak Tools Expanded",
|
||||
description="Show or hide the Tweak Tools section",
|
||||
default=False,
|
||||
)
|
||||
tweak_nla_track_name: StringProperty(
|
||||
name="NLA Track (bake range)",
|
||||
description="If set, bake uses this NLA track on the replacement armature for frame range; else scene range",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user