save startup blend for animation tab & whatnot
This commit is contained in:
2026-04-08 12:10:18 -06:00
parent 57a652524a
commit 692e200ffe
180 changed files with 12336 additions and 3431 deletions
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
id = "dynamiclinkmanager"
name = "Dynamic Link Manager"
tagline = "Character migrator and linked library tools"
version = "0.3.0"
version = "0.4.0"
type = "add-on"
# Optional: Semantic Versioning
@@ -5,11 +5,71 @@
"""Character migrator: migrate animation, constraints, relations from original to replacement armature."""
from contextlib import contextmanager
import bpy
from ..utils import descendants, collection_containing_armature
def _first_view3d_area(context):
win = getattr(context, "window", None)
if not win or not getattr(win, "screen", None):
return None, None
for area in win.screen.areas:
if area.type == "VIEW_3D":
return win, area
return None, None
@contextmanager
def _rep_active_for_animlayers(context, rep):
"""Make rep the only selected active object in Object mode so Animation Layers (als.turn_on) applies to rep.
Does not restore the previous selection: Anim Layers and the UI expect rep to stay active after MigNLA.
"""
if context is None or rep is None:
yield
return
vl = context.view_layer
win, area = _first_view3d_area(context)
# Ops need a VIEW_3D context; selection/active still use view_layer.
if win and area:
with context.temp_override(window=win, area=area):
if context.mode != "OBJECT":
try:
bpy.ops.object.mode_set(mode="OBJECT")
except Exception:
pass
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception:
pass
else:
if context.mode != "OBJECT":
try:
bpy.ops.object.mode_set(mode="OBJECT")
except Exception:
pass
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception:
pass
for ob in vl.objects:
try:
ob.select_set(False)
except Exception:
pass
try:
rep.select_set(True)
except Exception:
pass
vl.objects.active = rep
if bpy.context.view_layer == vl:
bpy.context.view_layer.objects.active = rep
yield
def get_pair_manual(context):
"""Return (orig_armature, rep_armature) from scene props, or (None, None)."""
props = getattr(context.scene, "dynamic_link_manager", None)
@@ -202,9 +262,10 @@ def _duplicate_action(src_action, suffix=".rep"):
return new_action
def run_mig_nla(orig, rep, report=None):
def run_mig_nla(orig, rep, report=None, context=None):
"""Migrate NLA: copy tracks and strips to replacement; or mirror action slot when no NLA (MigNLA).
Actions are duplicated so repchar has independent copies."""
Actions are duplicated so repchar has independent copies.
Pass context so Animation Layers mirroring runs with rep as active object."""
if not orig.animation_data:
return
ad = orig.animation_data
@@ -249,7 +310,8 @@ def run_mig_nla(orig, rep, report=None):
setattr(rad, prop, getattr(ad, prop))
print(f"[DLM MigNLA] set rep {prop}={getattr(ad, prop)!r}")
_slot_debug("Rep (after)", rad)
_mirror_als_turn_on(orig, rep)
with _rep_active_for_animlayers(context, rep):
_mirror_als_turn_on(orig, rep)
if report:
report({"INFO"}, "No NLA detected, active action (duplicated) and slot copied to Replacement Armature.")
return
@@ -296,7 +358,8 @@ def run_mig_nla(orig, rep, report=None):
new_strip.use_animated_time_cyclic = strip.use_animated_time_cyclic
new_strip.use_sync_length = strip.use_sync_length
prev_track = new_track
_mirror_als_turn_on(orig, rep)
with _rep_active_for_animlayers(context, rep):
_mirror_als_turn_on(orig, rep)
if report:
_debug_als_lookup(orig)
has_als = _has_als_anywhere(orig)
@@ -566,9 +629,126 @@ def _find_base_body(armature, descendants_iter, rep_base_name=None):
return candidates[0]
def _process_mig_bbody_mesh(orig_base, ob, context):
"""Library overrides on rep mesh ob; copy shape key values and action from orig_base mesh."""
# Debug: base body mesh state before override handling.
_lib = getattr(ob.data, "library", None)
_ol = getattr(ob.data, "override_library", None)
_sys = getattr(_ol, "is_system_override", None) if _ol else None
print(f"[DLM step6] {ob.name} data: linked={_lib is not None}, override={_ol is not None}, is_system_override={_sys}")
# Library override: use hierarchy create (fully editable) when context available, else single-id override.
if getattr(ob, "library", None):
if context:
try:
ob = ob.override_hierarchy_create(
context.scene, context.view_layer, do_fully_editable=True
)
except Exception:
try:
ob.override_create()
except Exception:
pass
else:
try:
ob.override_create()
except Exception:
pass
if getattr(ob.data, "library", None):
try:
ob.data.override_create(remap_local_usages=True)
# Make override user-editable (same as shift-click in data tab).
ol = getattr(ob.data, "override_library", None)
if ol is not None and getattr(ol, "is_system_override", None) is not None:
try:
ol.is_system_override = False
except Exception as e:
print(f"[DLM step6] {ob.name} set is_system_override=False: {e}")
except Exception as e:
print(f"[DLM step6] {ob.name} ob.data.override_create: {e}")
elif getattr(ob.data, "override_library", None):
ol = ob.data.override_library
if getattr(ol, "is_system_override", False):
try:
ol.is_system_override = False
except Exception as e:
print(f"[DLM step6] {ob.name} set is_system_override=False: {e}")
# Debug: state after override handling.
_ol2 = getattr(ob.data, "override_library", None)
_sys2 = getattr(_ol2, "is_system_override", None) if _ol2 else None
print(f"[DLM step6] {ob.name} after: override={_ol2 is not None}, is_system_override={_sys2} (False=editable)")
if ob.data.shape_keys:
# Ensure we can write shape key values: override the Key block if it is linked.
sk = ob.data.shape_keys
if getattr(sk, "library", None):
try:
sk.override_create(remap_local_usages=True)
except Exception as e:
print(f"[DLM step6] {ob.name} shape_keys.override_create: {e}")
# Copy shape key values from original base body to replacement (by matching key name).
if orig_base and orig_base.data.shape_keys:
rep_blocks = ob.data.shape_keys.key_blocks
orig_blocks = orig_base.data.shape_keys.key_blocks
n_copied = 0
for orig_key in orig_blocks:
rep_key = rep_blocks.get(orig_key.name)
if rep_key is not None:
rep_key.value = orig_key.value
n_copied += 1
print(f"[DLM step6] {ob.name} shapekey values: copied {n_copied}/{len(orig_blocks)} from {orig_base.name}")
else:
if not orig_base:
print(f"[DLM step6] {ob.name} no orig base body found")
elif not orig_base.data.shape_keys:
print(f"[DLM step6] {ob.name} orig base body has no shape_keys")
if not ob.data.shape_keys.animation_data:
ob.data.shape_keys.animation_data_create()
sk_ad = ob.data.shape_keys.animation_data
# Prefer action (and slot) from original base body; fallback to name lookup.
orig_sk_ad = None
if orig_base and orig_base.data.shape_keys:
orig_sk_ad = orig_base.data.shape_keys.animation_data
action = None
if orig_sk_ad and getattr(orig_sk_ad, "action", None):
action = orig_sk_ad.action
if action is None:
body_name = ob.name
action = (
bpy.data.actions.get(body_name + "Action")
or bpy.data.actions.get(ob.data.name + "Action")
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 = 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
except Exception:
pass
for prop in ("action_blend_type", "action_extrapolation", "action_influence"):
if orig_sk_ad and hasattr(orig_sk_ad, prop) and hasattr(sk_ad, prop):
try:
setattr(sk_ad, prop, getattr(orig_sk_ad, prop))
except Exception:
pass
def run_mig_bbody_shapekeys(orig, rep, rep_descendants, context=None):
"""Replacement base body: library override (fully editable when context given), copy shapekey values, then shape-key action."""
props = getattr(context.scene, "dynamic_link_manager", None) if context else None
if props and getattr(props, "migbbody_manual_override", False):
mo = getattr(props, "migbbody_orig_body", None)
mr = getattr(props, "migbbody_rep_body", None)
if mo and mr and mo.type == "MESH" and mr.type == "MESH":
_process_mig_bbody_mesh(mo, mr, context)
return
orig_descendants = list(descendants(orig))
any_auto = False
for ob in list(rep_descendants):
if not _base_body_name_match(ob):
continue
@@ -578,111 +758,11 @@ def run_mig_bbody_shapekeys(orig, rep, rep_descendants, context=None):
break
else:
continue
any_auto = True
orig_base = _find_base_body(orig, orig_descendants, rep_base_name=ob.name)
# Debug: base body mesh state before override handling.
_lib = getattr(ob.data, "library", None)
_ol = getattr(ob.data, "override_library", None)
_sys = getattr(_ol, "is_system_override", None) if _ol else None
print(f"[DLM step6] {ob.name} data: linked={_lib is not None}, override={_ol is not None}, is_system_override={_sys}")
# Library override: use hierarchy create (fully editable) when context available, else single-id override.
if getattr(ob, "library", None):
if context:
try:
ob = ob.override_hierarchy_create(
context.scene, context.view_layer, do_fully_editable=True
)
except Exception:
try:
ob.override_create()
except Exception:
pass
else:
try:
ob.override_create()
except Exception:
pass
if getattr(ob.data, "library", None):
try:
ob.data.override_create(remap_local_usages=True)
# Make override user-editable (same as shift-click in data tab).
ol = getattr(ob.data, "override_library", None)
if ol is not None and getattr(ol, "is_system_override", None) is not None:
try:
ol.is_system_override = False
except Exception as e:
print(f"[DLM step6] {ob.name} set is_system_override=False: {e}")
except Exception as e:
print(f"[DLM step6] {ob.name} ob.data.override_create: {e}")
elif getattr(ob.data, "override_library", None):
ol = ob.data.override_library
if getattr(ol, "is_system_override", False):
try:
ol.is_system_override = False
except Exception as e:
print(f"[DLM step6] {ob.name} set is_system_override=False: {e}")
# Debug: state after override handling.
_ol2 = getattr(ob.data, "override_library", None)
_sys2 = getattr(_ol2, "is_system_override", None) if _ol2 else None
print(f"[DLM step6] {ob.name} after: override={_ol2 is not None}, is_system_override={_sys2} (False=editable)")
if ob.data.shape_keys:
# Ensure we can write shape key values: override the Key block if it is linked.
sk = ob.data.shape_keys
if getattr(sk, "library", None):
try:
sk.override_create(remap_local_usages=True)
except Exception as e:
print(f"[DLM step6] {ob.name} shape_keys.override_create: {e}")
# Copy shape key values from original base body to replacement (by matching key name).
if orig_base and orig_base.data.shape_keys:
rep_blocks = ob.data.shape_keys.key_blocks
orig_blocks = orig_base.data.shape_keys.key_blocks
n_copied = 0
for orig_key in orig_blocks:
rep_key = rep_blocks.get(orig_key.name)
if rep_key is not None:
rep_key.value = orig_key.value
n_copied += 1
print(f"[DLM step6] {ob.name} shapekey values: copied {n_copied}/{len(orig_blocks)} from {orig_base.name}")
else:
if not orig_base:
print(f"[DLM step6] {ob.name} no orig base body found for armature {orig.name}")
elif not orig_base.data.shape_keys:
print(f"[DLM step6] {ob.name} orig base body has no shape_keys")
if not ob.data.shape_keys.animation_data:
ob.data.shape_keys.animation_data_create()
sk_ad = ob.data.shape_keys.animation_data
# Prefer action (and slot) from original base body; fallback to name lookup.
orig_sk_ad = None
if orig_base and orig_base.data.shape_keys:
orig_sk_ad = orig_base.data.shape_keys.animation_data
action = None
if orig_sk_ad and getattr(orig_sk_ad, "action", None):
action = orig_sk_ad.action
if action is None:
body_name = ob.name
action = (
bpy.data.actions.get(body_name + "Action")
or bpy.data.actions.get(ob.data.name + "Action")
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 = 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
except Exception:
pass
for prop in ("action_blend_type", "action_extrapolation", "action_influence"):
if orig_sk_ad and hasattr(orig_sk_ad, prop) and hasattr(sk_ad, prop):
try:
setattr(sk_ad, prop, getattr(orig_sk_ad, prop))
except Exception:
pass
_process_mig_bbody_mesh(orig_base, ob, context)
if not any_auto and props is not None:
props.migbbody_manual_override = True
def run_full_migration(context):
@@ -703,7 +783,7 @@ def run_full_migration(context):
try:
run_copy_attr(orig, rep)
run_mig_nla(orig, rep)
run_mig_nla(orig, rep, context=context)
run_mig_cust_props(orig, rep)
run_mig_bone_const(orig, rep, orig_to_rep)
run_retarg_relatives(orig, rep, rep_descendants, orig_to_rep)
@@ -9,7 +9,7 @@ from bpy.types import Operator
from bpy.props import StringProperty, BoolProperty
from bpy.props import StringProperty, IntProperty
from ..utils import collection_containing_armature
from ..utils.remove_original import resolve_collection_for_remove_original
ADDON_NAME = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
@@ -305,7 +305,7 @@ class DLM_OT_migrator_migrate_nla(Operator):
return {"CANCELLED"}
try:
from ..ops.migrator import run_mig_nla
run_mig_nla(orig, rep, report=self.report)
run_mig_nla(orig, rep, report=self.report, context=context)
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, str(e))
@@ -399,7 +399,16 @@ class DLM_OT_migrator_basebody_shapekeys(Operator):
from ..utils import descendants
rep_descendants = descendants(rep)
run_mig_bbody_shapekeys(orig, rep, rep_descendants, context)
self.report({"INFO"}, "Migrate BaseBody shapekeys done.")
props = context.scene.dynamic_link_manager
if props.migbbody_manual_override and (
not props.migbbody_orig_body or not props.migbbody_rep_body
):
self.report(
{"WARNING"},
"MigBBody: no CC-style base mesh matched. Pick Original/Replacement body meshes, then run again.",
)
else:
self.report({"INFO"}, "Migrate BaseBody shapekeys done.")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, str(e))
@@ -531,9 +540,10 @@ class DLM_OT_migrator_remove_original(Operator):
if removed_actions:
self.report({"INFO"}, f"Removed {len(removed_actions)} action(s) from original")
props = context.scene.dynamic_link_manager
rig_family = getattr(props, "migrator_rig_family", "RIGIFY")
try:
# Try to find and delete the collection containing the original character
coll = collection_containing_armature(orig)
coll = resolve_collection_for_remove_original(orig, rig_family, context.scene, rep)
if coll:
coll_name = coll.name # Store name BEFORE removal (RNA invalidates after remove)
context.scene.dynamic_link_manager.original_character = None
@@ -541,10 +551,14 @@ class DLM_OT_migrator_remove_original(Operator):
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}")
try:
bpy.data.objects.remove(orig, do_unlink=True)
self.report({"INFO"}, f"Removed original object: {name}")
except Exception as e2:
self.report({"ERROR"}, f"Could not remove original after collection failure: {e2}")
return {"CANCELLED"}
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}")
@@ -76,6 +76,8 @@ class DLM_PT_main_panel(Panel):
box = layout.box()
box.label(text="Character Migrator")
row = box.row()
row.prop(props, "migrator_rig_family", expand=True)
row = box.row()
row.prop(props, "migrator_mode", text="Automatic pair discovery")
row = box.row()
row.prop(props, "original_character", text="Original")
@@ -96,6 +98,14 @@ class DLM_PT_main_panel(Panel):
# Situational
situational_box = layout.box()
situational_box.label(text="Situational Fixes", icon="QUESTION")
row = situational_box.row()
row.prop(props, "migbbody_manual_override", text="Manual body meshes")
row = situational_box.row()
row.enabled = props.migbbody_manual_override
row.prop(props, "migbbody_orig_body", text="Original body")
row = situational_box.row()
row.enabled = props.migbbody_manual_override
row.prop(props, "migbbody_rep_body", text="Replacement body")
row = situational_box.row(align=True)
row.operator("dlm.migrator_basebody_shapekeys", text="MigBBodyShapeKeys", icon="SHAPEKEY_DATA")
row = situational_box.row(align=True)
@@ -5,7 +5,14 @@
import bpy
from bpy.types import PropertyGroup
from bpy.props import IntProperty, StringProperty, BoolProperty, CollectionProperty, PointerProperty
from bpy.props import (
IntProperty,
StringProperty,
BoolProperty,
CollectionProperty,
PointerProperty,
EnumProperty,
)
class SearchPathItem(PropertyGroup):
@@ -41,6 +48,17 @@ class DynamicLinkManagerProperties(PropertyGroup):
)
selected_asset_path: StringProperty(name="Selected Asset Path", default="")
# Character migrator: rig family (future ARP-specific migration; Remove Original uses it for collection resolution)
migrator_rig_family: EnumProperty(
name="Rig family",
description="Rigify vs Auto-Rig Pro: affects Remove Original collection detection and future bone heuristics",
items=(
("RIGIFY", "Rigify", "Rigify rig naming and defaults"),
("ARP", "ARP", "Auto-Rig Pro rig naming and collection behavior"),
),
default="RIGIFY",
)
# Character migrator (manual mode)
migrator_mode: BoolProperty(
name="Automatic",
@@ -60,6 +78,28 @@ class DynamicLinkManagerProperties(PropertyGroup):
poll=lambda self, obj: obj and obj.type == "ARMATURE",
)
# MigBBody: manual mesh pair when CC/iClone-style auto-detection fails
migbbody_manual_override: BoolProperty(
name="Manual body meshes",
description=(
"Enable to pick original and replacement body meshes manually. "
"Auto-enables if MigBBody cannot find a base mesh (non-CC rigs)."
),
default=False,
)
migbbody_orig_body: PointerProperty(
name="Original body",
description="Original character body mesh (shape key source)",
type=bpy.types.Object,
poll=lambda self, obj: obj and obj.type == "MESH",
)
migbbody_rep_body: PointerProperty(
name="Replacement body",
description="Replacement character body mesh (shape key target)",
type=bpy.types.Object,
poll=lambda self, obj: obj and obj.type == "MESH",
)
# Tweak tools (collapsible section)
tweak_tools_section_expanded: BoolProperty(
name="Tweak Tools Expanded",
@@ -0,0 +1,140 @@
# 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.
"""Resolve which collection to remove when deleting the original character armature."""
import bpy
def _parent_collection(scene, coll):
"""Return the parent of coll in the scene tree, or None if not found."""
if coll is None:
return None
master = scene.collection
# Blender 5+: children.__contains__ expects collection name strings, not Collection objects.
if coll.name in master.children:
return master
for p in bpy.data.collections:
if coll.name in p.children:
return p
return None
def _depth_from_scene_root(scene, coll):
"""Depth: number of steps walking up from coll until scene.collection (deeper = larger)."""
d = 0
cur = coll
while cur is not None and cur != scene.collection:
d += 1
cur = _parent_collection(scene, cur)
return d
def _walk_up_chain(scene, coll):
"""Return [inner, ..., top] where top is a direct child of scene.collection (or highest ancestor)."""
chain = []
cur = coll
while cur is not None and cur != scene.collection:
chain.append(cur)
cur = _parent_collection(scene, cur)
return chain
def _collection_contains_object_recursive(coll, ob):
"""True if ob is in coll.objects or in any descendant collection (recursive)."""
if coll is None or ob is None:
return False
for o in coll.objects:
if o == ob:
return True
for child in coll.children:
if _collection_contains_object_recursive(child, ob):
return True
return False
def _deepest_users_collection(scene, armature):
"""Among armature.users_collection, pick the most nested (max depth) as the inner anchor."""
colls = list(getattr(armature, "users_collection", []) or [])
if not colls:
return None
best = colls[0]
best_d = _depth_from_scene_root(scene, best)
for c in colls[1:]:
d = _depth_from_scene_root(scene, c)
if d > best_d:
best_d = d
best = c
return best
def _library_ptr(lib):
"""Stable id for comparing library datablocks."""
return lib.filepath if lib else None
def _pick_remove_target_from_chain(orig, chain, rig_family):
"""
From walk-up chain [inner..top], choose one collection to remove.
Prefer the topmost under scene (chain[-1]) so nested linked setups remove the whole instance.
If orig is linked, prefer the outermost chain entry whose library matches orig.library.
"""
if not chain:
return None
orig_lib = _library_ptr(getattr(orig, "library", None))
# Linked armature: outermost in chain from same library file (reversed = top -> inner)
if orig_lib:
for c in reversed(chain):
if _library_ptr(getattr(c, "library", None)) == orig_lib:
return c
# Local override: prefer outermost collection that participates in override hierarchy
if getattr(orig, "override_library", None):
for c in reversed(chain):
if getattr(c, "override_library", None) is not None:
return c
# Rigify: optional name hint — prefer collection whose name matches base armature name
if rig_family == "RIGIFY":
name = orig.name
base = name.replace("_Rigify", "").replace(".001", "").rstrip("0123456789.")
for c in reversed(chain):
if c.name == base or base in c.name or c.name in name:
return c
# Default: top of chain (direct child of scene.collection subtree root)
return chain[-1]
def resolve_collection_for_remove_original(orig, rig_family, scene, rep=None):
"""
Return a collection to remove for Remove Original, or None to fall back to object-only removal.
Walks up from the deepest users_collection so nested linked rigs remove the outer instance,
not an inner linked child collection.
If rep is the replacement armature, never remove a collection whose subtree contains rep
(avoids deleting both characters when they share a parent collection).
rig_family: 'RIGIFY' | 'ARP' (ARP skips Rigify name heuristics in _pick_remove_target_from_chain).
"""
if not orig or orig.type != "ARMATURE" or orig.name not in bpy.data.objects:
return None
inner = _deepest_users_collection(scene, orig)
if inner is None:
return None
chain = _walk_up_chain(scene, inner)
if not chain:
return None
if rep is not None and rep.name in bpy.data.objects:
chain = [c for c in chain if not _collection_contains_object_recursive(c, rep)]
if not chain:
return None
return _pick_remove_target_from_chain(orig, chain, rig_family)