2026-03-11_1

This commit is contained in:
2026-03-17 15:29:47 -06:00
parent dc97400d1e
commit 32d4247d4d
217 changed files with 2683 additions and 157239 deletions
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,9 +1,9 @@
schema_version = "1.0.0"
id = "add_camera_rigs"
name = "Add Camera Rigs"
version = "1.8.1"
version = "1.8.2"
tagline = "Adds a Camera Rig with UI"
maintainer = "Community"
maintainer = "Wayne Dixon <waylowinternet@gmail.com>"
type = "add-on"
tags = ["Camera"]
blender_version_min = "4.2.0"
@@ -20,6 +20,15 @@ from .create_widgets import (
)
def lock_mch_transforms(rig):
for bone in rig.pose.bones:
if bone.name.startswith("MCH-"):
bone.lock_location = (True, True, True)
bone.lock_rotation = (True, True, True)
bone.lock_rotation_w = True
bone.lock_scale = (True, True, True)
def create_prop_driver(rig, cam, prop_from, prop_to):
"""Create driver to a property on the rig"""
driver = cam.data.driver_add(prop_to)
@@ -52,7 +61,7 @@ def create_dolly_bones(rig):
root.color.palette = 'THEME02'
collection_controls.assign(root)
ctrl_aim_child = bones.new("MCH-Aim_shape_rotation")
ctrl_aim_child = bones.new("MCH-Aim_widget")
ctrl_aim_child.head = (0.0, 10.0, 1.7)
ctrl_aim_child.tail = (0.0, 11.0, 1.7)
collection_mch.assign(ctrl_aim_child)
@@ -107,7 +116,7 @@ def create_crane_bones(rig):
root.color.palette = 'THEME02'
collection_controls.assign(root)
ctrl_aim_child = bones.new("MCH-Aim_shape_rotation")
ctrl_aim_child = bones.new("MCH-Aim_widget")
ctrl_aim_child.head = (0.0, 10.0, 1.7)
ctrl_aim_child.tail = (0.0, 11.0, 1.7)
collection_mch.assign(ctrl_aim_child)
@@ -147,7 +156,6 @@ def create_crane_bones(rig):
height.color.palette = 'THEME07'
collection_controls.assign(height)
# Setup hierarchy
ctrl.parent = arm
ctrl_offset.parent = ctrl
@@ -187,13 +195,15 @@ def setup_3d_rig(rig, cam):
pb = pose_bones['Camera']
pb["lens"] = 50.0
ui_data = pb.id_properties_ui("lens")
ui_data.update(min=1.0, max=1000000.0, soft_max=5000.0, default=50.0, subtype="DISTANCE_CAMERA")
ui_data.update(min=1.0, max=1000000.0, soft_max=5000.0,
default=50.0, subtype="DISTANCE_CAMERA")
# lens offset property
pb = pose_bones['Camera']
pb["lens_offset"] = 0.0
ui_data = pb.id_properties_ui("lens_offset")
ui_data.update(min=-1000000.0, max=1000000.0, soft_max = 5000.0, soft_min = -5000.0,default=0.0)
ui_data.update(min=-1000000.0, max=1000000.0,
soft_max=5000.0, soft_min=-5000.0, default=0.0)
# Build the widgets
root_widget = create_root_widget("Camera_Root")
@@ -207,11 +217,13 @@ def setup_3d_rig(rig, cam):
pose_bones["Camera"].custom_shape = camera_widget
pose_bones["Camera_Offset"].custom_shape = camera_offset_widget
#
# Set the "Override Transform" field to the mechanism position
pose_bones["Aim"].custom_shape_transform = pose_bones["MCH-Aim_shape_rotation"]
pose_bones["Aim"].custom_shape_transform = pose_bones["MCH-Aim_widget"]
# Add constraints to bones
con = pose_bones['MCH-Aim_shape_rotation'].constraints.new('COPY_ROTATION')
con = pose_bones['MCH-Aim_widget'].constraints.new('COPY_ROTATION')
con.target = rig
con.subtarget = "Camera"
@@ -249,6 +261,9 @@ def setup_3d_rig(rig, cam):
var.targets[0].transform_type = 'SCALE_AVG'
var.targets[0].bone_target = 'Root'
# lock all transforms on MCH bones
lock_mch_transforms(rig)
def create_2d_bones(rig, cam):
"""Create bones for the 2D camera rig"""
@@ -476,8 +491,10 @@ def create_2d_bones(rig, cam):
pose_bones["Left_Corner"].lock_rotation = (True,) * 3
pose_bones["Right_Corner"].lock_rotation = (True,) * 3
# Camera settings
# lock all transforms on MCH bones
lock_mch_transforms(rig)
# Camera settings
cam.data.sensor_fit = "HORIZONTAL" # Avoids distortion in portrait format
cam.data.dof.focus_object = rig
cam.data.dof.focus_subtarget = "DOF"
@@ -539,7 +556,8 @@ def create_2d_bones(rig, cam):
# Orthographic scale driver
driver = cam.data.driver_add("ortho_scale").driver
driver.expression = "abs({distance_x} - (left_x - right_x))".format(distance_x=corner_distance_x)
driver.expression = "abs({distance_x} - (left_x - right_x))".format(
distance_x=corner_distance_x)
for corner in ("left", "right"):
var = driver.variables.new()
@@ -694,6 +712,11 @@ def build_camera_rig(context, mode):
rig.data.display_type = 'BBONE'
# Change display to wire for object
rig.display_type = 'WIRE'
# Set the widgets to be 1.5 width (easier to see)
if bpy.app.version >= (4, 2, 0):
for bone in rig.pose.bones:
if bone.custom_shape:
bone.custom_shape_wire_width = 1.5
# Lock camera transforms
cam.lock_location = (True,) * 3
@@ -27,6 +27,7 @@ def get_rig_and_cam(obj):
return obj.parent, obj
return None, None
def calculate_aim_distance(obj):
'''This will return the distance of the camera and the aim bone at the time it is called.'''
camera_bone = obj.pose.bones['Camera'].matrix
@@ -53,10 +54,12 @@ def poll_perspective(cls, context):
rig, cam = get_rig_and_cam(context.active_object)
if cam.data.type == 'ORTHO':
cls.poll_message_set("This operator is not supported for orthographic cameras.")
cls.poll_message_set(
"This operator is not supported for orthographic cameras.")
return False
if rig["rig_id"].lower() == '2d_rig':
cls.poll_message_set("This operator is not supported for 2D camera rigs.")
cls.poll_message_set(
"This operator is not supported for 2D camera rigs.")
return False
return True
@@ -73,7 +76,8 @@ class ADD_CAMERA_RIGS_OT_set_scene_camera(Operator):
_rig, cam = get_rig_and_cam(context.active_object)
if cam is context.scene.camera:
cls.poll_message_set("Selected camera is already the scene camera.")
cls.poll_message_set(
"Selected camera is already the scene camera.")
return False
return True
@@ -120,7 +124,7 @@ class ADD_CAMERA_RIGS_OT_set_dof_bone(Operator):
cam.data.dof.focus_object = rig
cam.data.dof.focus_subtarget = (
'DOF' if rig["rig_id"].lower() == '2d_rig'
else 'MCH-Aim_shape_rotation')
else 'Aim')
return {'FINISHED'}
@@ -169,7 +173,7 @@ class ADD_CAMERA_RIGS_OT_remove_dolly_zoom(Operator):
# reset the offset back to zero
rig.pose.bones["Camera"]["lens_offset"] = 0.0
#set the bone color to default
# set the bone color to default
rig.pose.bones["Aim"].color.palette = 'DEFAULT'
return {'FINISHED'}
@@ -191,16 +195,20 @@ class ADD_CAMERA_RIGS_OT_shift_to_pivot(Operator):
aim_loc = rig.pose.bones["Aim"].matrix_basis.to_translation()
# create a transform matrix for the z loc of the aim bone
mat_trans = mathutils.Matrix.Translation( [0, 0, aim_loc[2] + 1.7 ]) # Hardcoded height of rest position
mat_trans = mathutils.Matrix.Translation(
[0, 0, aim_loc[2] + 1.7]) # Hardcoded height of rest position
# repostion the aim bone so it's above the root (using the original z value)
rig.pose.bones["Aim"].matrix = rig.pose.bones["Root"].matrix @ mat_trans
# offset the camera matrix relative to the new aim position
camera_offset_vector = (rig.pose.bones["Aim"].matrix_basis.to_translation() ) - aim_loc
camera_offset_matrix = mathutils.Matrix.Translation(camera_offset_vector)
camera_offset_vector = (
rig.pose.bones["Aim"].matrix_basis.to_translation()) - aim_loc
camera_offset_matrix = mathutils.Matrix.Translation(
camera_offset_vector)
rig.pose.bones["Camera"].matrix = rig.pose.bones["Camera"].matrix @ camera_offset_matrix
return {'FINISHED'}
class ADD_CAMERA_RIGS_OT_swap_lens(Operator):
bl_idname = "add_camera_rigs.swap_lens"
bl_label = "Swap Lens"
@@ -0,0 +1,100 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.3",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.3/BasedPlayblast.v2.6.3.zip",
"archive_size": 49732,
"archive_hash": "sha256:078b406105ce6f4802e75233569841e2f73d082e09cd1d954696681ebf72b627"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.16.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.16.0/Rainys_Bulk_Scene_Tools.v0.16.0.zip",
"archive_size": 80251,
"archive_hash": "sha256:3e6fafe11caa39e48b94288c12b2a88e521c928955a854ffdd1bd0936e6bc70a"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.5.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.5.0/Atomic_Data_Manager.v2.5.0.zip",
"archive_size": 114674,
"archive_hash": "sha256:4b4834ed3910a428d4cb01f1891247ad80089b6c5324fc27c6862b09e81ff1c1"
},
{
"schema_version": "1.0.0",
"id": "sheepit_project_submitter",
"name": "SheepIt Project Submitter",
"tagline": "Submit projects to SheepIt render farm",
"version": "0.0.7",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "3.0.0",
"tags": [
"render",
"farm",
"submission",
"utility"
],
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.7/SheepIt_Project_Submitter.v0.0.7.zip",
"archive_size": 47250,
"archive_hash": "sha256:cb8dee48c45cc51dd8237981f4ab96d97d476b547c8c640606e9bbfd0390a055"
}
]
}
@@ -0,0 +1,100 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.3",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.3/BasedPlayblast.v2.6.3.zip",
"archive_size": 49732,
"archive_hash": "sha256:078b406105ce6f4802e75233569841e2f73d082e09cd1d954696681ebf72b627"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.16.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.16.0/Rainys_Bulk_Scene_Tools.v0.16.0.zip",
"archive_size": 80251,
"archive_hash": "sha256:3e6fafe11caa39e48b94288c12b2a88e521c928955a854ffdd1bd0936e6bc70a"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.5.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.5.0/Atomic_Data_Manager.v2.5.0.zip",
"archive_size": 114674,
"archive_hash": "sha256:4b4834ed3910a428d4cb01f1891247ad80089b6c5324fc27c6862b09e81ff1c1"
},
{
"schema_version": "1.0.0",
"id": "sheepit_project_submitter",
"name": "SheepIt Project Submitter",
"tagline": "Submit projects to SheepIt render farm",
"version": "0.0.7",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "3.0.0",
"tags": [
"render",
"farm",
"submission",
"utility"
],
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.7/SheepIt_Project_Submitter.v0.0.7.zip",
"archive_size": 47250,
"archive_hash": "sha256:cb8dee48c45cc51dd8237981f4ab96d97d476b547c8c640606e9bbfd0390a055"
}
]
}
@@ -0,0 +1,78 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.2",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.2/BasedPlayblast.v2.6.2.zip",
"archive_size": 48968,
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.15.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.15.1/Rainys_Bulk_Scene_Tools.v0.15.1.zip",
"archive_size": 81044,
"archive_hash": "sha256:a72f7dbf7c35fda94a7b67df79ef131391e0fe2ac4f416703b07ef59afd7235b"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.4.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.4.1/Atomic_Data_Manager.v2.4.1.zip",
"archive_size": 108842,
"archive_hash": "sha256:4086ada3e9e8c852fd02d455f11f2f20fd19ca68acd10b101ab3aa0fae2be210"
}
]
}
@@ -0,0 +1,100 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.2",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.2/BasedPlayblast.v2.6.2.zip",
"archive_size": 48968,
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.15.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.15.1/Rainys_Bulk_Scene_Tools.v0.15.1.zip",
"archive_size": 81044,
"archive_hash": "sha256:a72f7dbf7c35fda94a7b67df79ef131391e0fe2ac4f416703b07ef59afd7235b"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.4.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.4.1/Atomic_Data_Manager.v2.4.1.zip",
"archive_size": 108842,
"archive_hash": "sha256:4086ada3e9e8c852fd02d455f11f2f20fd19ca68acd10b101ab3aa0fae2be210"
},
{
"schema_version": "1.0.0",
"id": "sheepit_project_submitter",
"name": "SheepIt Project Submitter",
"tagline": "Submit projects to SheepIt render farm",
"version": "0.0.2",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "3.0.0",
"tags": [
"render",
"farm",
"submission",
"utility"
],
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.2/SheepIt_Project_Submitter.v0.0.2.zip",
"archive_size": 64260,
"archive_hash": "sha256:0149a702a4caef68a5c4f7cb102ea51331d97446d8f1a57252f9ff3b44467ad0"
}
]
}
@@ -0,0 +1,78 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.2",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.2/BasedPlayblast.v2.6.2.zip",
"archive_size": 48968,
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.15.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.15.0/Rainys_Bulk_Scene_Tools.v0.15.0.zip",
"archive_size": 80698,
"archive_hash": "sha256:bbde2f4056d39f9b16072a45bc47a31c19e6beb24616101bdf9e556b99f9853c"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.3.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.3.0/Atomic_Data_Manager.v2.3.0.zip",
"archive_size": 92609,
"archive_hash": "sha256:be0304820428e461c3ecda4ab652d5c84d3df9c0548292870350ca86a9ba828c"
}
]
}
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
id = "amzncharactertools"
name = "AMZNCharacterTools"
tagline = "AMZNCharacterTools"
version = "0.10.1"
version = "0.10.2"
type = "add-on"
maintainer = "Nathan Lindsay"
@@ -89,7 +89,7 @@ def create_settings_bone():
pose_bone.select = True
elif hasattr(pose_bone.bone, 'select'):
# Blender 4.2 LTS, 4.5 LTS
pose_bone.bone.select = True
pose_bone.bone.select = True
armature_obj.data.bones.active = pose_bone.bone
@@ -1,5 +1,5 @@
{
"last_check": "2026-02-12 10:20:22.908483",
"last_check": "2026-03-05 13:07:15.606509",
"backup_date": "January-12-2026",
"update_ready": false,
"ignore": false,
@@ -12,32 +12,36 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bpy.props import StringProperty, BoolProperty, EnumProperty
from bpy.types import Panel, Operator, PropertyGroup
# Import local modules
from . import operators
from . import ui
from .ui import CLASSES
from .ui.properties import DynamicLinkManagerProperties
from .ui.preferences import DynamicLinkManagerPreferences
def ensure_default_search_path():
"""Ensure there's always at least one search path"""
prefs = bpy.context.preferences.addons.get(__name__)
if prefs and len(prefs.preferences.search_paths) == 0:
new_path = prefs.preferences.search_paths.add()
new_path.path = "//" # Default to relative path
addon = bpy.context.preferences.addons.get(__name__)
if addon and len(addon.preferences.search_paths) == 0:
addon.preferences.search_paths.add().path = "//"
def register():
operators.register()
ui.register()
# Ensure default search path exists
DynamicLinkManagerPreferences.bl_idname = __name__
for cls in CLASSES:
bpy.utils.register_class(cls)
bpy.types.Scene.dynamic_link_manager = bpy.props.PointerProperty(type=DynamicLinkManagerProperties)
bpy.app.handlers.load_post.append(ensure_default_search_path)
def unregister():
ui.unregister()
operators.unregister()
# Remove the handler
if ensure_default_search_path in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(ensure_default_search_path)
del bpy.types.Scene.dynamic_link_manager
for cls in reversed(CLASSES):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
pass
if __name__ == "__main__":
register()
@@ -2,8 +2,8 @@ schema_version = "1.0.0"
id = "dynamiclinkmanager"
name = "Dynamic Link Manager"
tagline = "Relink characters and library blends with ease"
version = "0.0.1"
tagline = "Character migrator and linked library tools"
version = "0.1.1"
type = "add-on"
# Optional: Semantic Versioning
@@ -1,525 +0,0 @@
import bpy
import os
from bpy.types import Operator
from bpy.props import StringProperty, BoolProperty, EnumProperty, IntProperty
class DLM_OT_replace_linked_asset(Operator):
"""Replace a linked asset with a new file"""
bl_idname = "dlm.replace_linked_asset"
bl_label = "Replace Linked Asset"
bl_options = {'REGISTER', 'UNDO'}
filepath: StringProperty(
name="File Path",
description="Path to the new asset file",
subtype='FILE_PATH'
)
def execute(self, context):
obj = context.active_object
if not obj:
self.report({'ERROR'}, "No object selected")
return {'CANCELLED'}
# Comprehensive debug info
debug_info = f"Object: {obj.name}, Type: {obj.type}"
# Check object library
if hasattr(obj, 'library'):
debug_info += f", Object has library attr: {obj.library is not None}"
if obj.library:
debug_info += f", Object library: {obj.library.filepath}"
# Check object data
if obj.data:
debug_info += f", Has data: {type(obj.data).__name__}, Name: {obj.data.name}"
# Check data library attribute
if hasattr(obj.data, 'library'):
debug_info += f", Data.library exists: {obj.data.library is not None}"
if obj.data.library:
debug_info += f", Data.library.filepath: {obj.data.library.filepath}"
# Check if data is in bpy.data collections
if obj.type == 'ARMATURE' and obj.data.name in bpy.data.armatures:
armature_data = bpy.data.armatures[obj.data.name]
debug_info += f", Found in bpy.data.armatures"
if hasattr(armature_data, 'library'):
debug_info += f", bpy.data library: {armature_data.library is not None}"
if armature_data.library:
debug_info += f", bpy.data library path: {armature_data.library.filepath}"
# Check if data is in bpy.data.objects
if obj.data.name in bpy.data.objects:
debug_info += f", Data also in bpy.data.objects"
# Check if object itself is linked
if hasattr(obj, 'library') and obj.library:
self.report({'INFO'}, f"Object '{obj.name}' is linked from: {obj.library.filepath}")
return {'FINISHED'}
# Check if object's data is linked
if obj.data and hasattr(obj.data, 'library') and obj.data.library:
self.report({'INFO'}, f"Object '{obj.name}' has linked data from: {obj.data.library.filepath}")
return {'FINISHED'}
# Check if armature data is linked through bpy.data system
if obj.type == 'ARMATURE' and obj.data and obj.data.name in bpy.data.armatures:
armature_data = bpy.data.armatures[obj.data.name]
if hasattr(armature_data, 'library') and armature_data.library:
self.report({'INFO'}, f"Armature '{obj.name}' data is linked from: {armature_data.library.filepath}")
return {'FINISHED'}
# If we get here, show debug info
self.report({'WARNING'}, debug_info)
self.report({'ERROR'}, "Selected object is not a linked asset")
return {'CANCELLED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class DLM_OT_scan_linked_assets(Operator):
"""Scan scene for all linked assets"""
bl_idname = "dlm.scan_linked_assets"
bl_label = "Scan Linked Assets"
def execute(self, context):
# Clear previous results
context.scene.dynamic_link_manager.linked_libraries.clear()
# Dictionary to store library info with hierarchy
library_info = {}
# Function to check if file exists
def is_file_missing(filepath):
if not filepath:
return True
# Convert relative paths to absolute
if filepath.startswith('//'):
# This is a relative path, we can't easily check if it exists
# So we'll assume it's missing if it's relative
return True
return not os.path.exists(filepath)
# Function to get library name from path
def get_library_name(filepath):
if not filepath:
return "Unknown"
return os.path.basename(filepath)
# Function to detect indirect links by parsing .blend files safely
def get_indirect_libraries(filepath):
"""Get libraries that are linked from within a .blend file"""
# This function is no longer used with the new approach
# Indirect links are now detected when attempting to relink
return set()
# Scan all data collections for linked items
all_libraries = set()
library_info = {} # Store additional info about each library
# Check bpy.data.objects
for obj in bpy.data.objects:
if hasattr(obj, 'library') and obj.library:
all_libraries.add(obj.library.filepath)
if obj.data and hasattr(obj.data, 'library') and obj.data.library:
all_libraries.add(obj.data.library.filepath)
# Check bpy.data.armatures specifically
for armature in bpy.data.armatures:
if hasattr(armature, 'library') and armature.library:
all_libraries.add(armature.library.filepath)
# Check bpy.data.meshes
for mesh in bpy.data.meshes:
if hasattr(mesh, 'library') and mesh.library:
all_libraries.add(mesh.library.filepath)
# Check bpy.data.materials
for material in bpy.data.materials:
if hasattr(material, 'library') and material.library:
all_libraries.add(material.library.filepath)
# Check bpy.data.images
for image in bpy.data.images:
if hasattr(image, 'library') and image.library:
all_libraries.add(image.library.filepath)
# Check bpy.data.textures
for texture in bpy.data.textures:
if hasattr(texture, 'library') and texture.library:
all_libraries.add(texture.library.filepath)
# Check bpy.data.node_groups
for node_group in bpy.data.node_groups:
if hasattr(node_group, 'library') and node_group.library:
all_libraries.add(node_group.library.filepath)
# Analyze each library for indirect links
for filepath in all_libraries:
if filepath:
# Initialize with no indirect missing (will be updated during relink attempts)
library_info[filepath] = {
'indirect_libraries': set(),
'missing_indirect_count': 0,
'has_indirect_missing': False
}
# Store results in scene properties
context.scene.dynamic_link_manager.linked_assets_count = len(all_libraries)
# Create library items for the UI
for filepath in sorted(all_libraries):
if filepath:
lib_item = context.scene.dynamic_link_manager.linked_libraries.add()
lib_item.filepath = filepath
lib_item.name = get_library_name(filepath)
lib_item.is_missing = is_file_missing(filepath)
# Set indirect link information
if filepath in library_info:
info = library_info[filepath]
lib_item.has_indirect_missing = info['has_indirect_missing']
lib_item.indirect_missing_count = info['missing_indirect_count']
else:
lib_item.has_indirect_missing = False
lib_item.indirect_missing_count = 0
# Show detailed info
self.report({'INFO'}, f"Found {len(all_libraries)} unique linked library files")
if all_libraries:
for lib in sorted(all_libraries):
self.report({'INFO'}, f"Library: {lib}")
return {'FINISHED'}
class DLM_OT_fmt_style_find(Operator):
"""Find missing libraries in search folders using FMT-style approach"""
bl_idname = "dlm.fmt_style_find"
bl_label = "FMT-Style Find"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
prefs = context.preferences.addons.get(__package__)
if not prefs or not prefs.preferences.search_paths:
self.report({'ERROR'}, "No search paths configured. Add search paths in addon preferences.")
return {'CANCELLED'}
# Get missing libraries
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
if not missing_libs:
self.report({'INFO'}, "No missing libraries to find")
return {'FINISHED'}
self.report({'INFO'}, f"FMT-style search for {len(missing_libs)} missing libraries...")
# FMT-style directory scanning (exact copy of FMT logic)
files_dir_list = []
try:
for search_path in prefs.preferences.search_paths:
if search_path.path:
for dirpath, dirnames, filenames in os.walk(bpy.path.abspath(search_path.path)):
files_dir_list.append([dirpath, filenames])
except FileNotFoundError:
self.report({'ERROR'}, f"Error - Bad file path in search paths")
return {'CANCELLED'}
# FMT-style library finding
found_count = 0
for lib_item in missing_libs:
lib_filename = os.path.basename(lib_item.filepath)
for dir_info in files_dir_list:
dirpath, filenames = dir_info
# Exact filename match
if lib_filename in filenames:
new_path = os.path.join(dirpath, lib_filename)
self.report({'INFO'}, f"Found {lib_filename} at: {new_path}")
found_count += 1
break
# FMT-style reporting
if found_count > 0:
self.report({'INFO'}, f"FMT-style search complete: Found {found_count} libraries")
else:
self.report({'WARNING'}, "FMT-style search: No libraries found in search paths")
return {'FINISHED'}
def register():
bpy.utils.register_class(DLM_OT_replace_linked_asset)
bpy.utils.register_class(DLM_OT_scan_linked_assets)
class DLM_OT_open_linked_file(Operator):
"""Open the linked file in a new Blender instance"""
bl_idname = "dlm.open_linked_file"
bl_label = "Open Linked File"
bl_options = {'REGISTER'}
filepath: StringProperty(
name="File Path",
description="Path to the linked file",
default=""
)
def execute(self, context):
if not self.filepath:
self.report({'ERROR'}, "No file path specified")
return {'CANCELLED'}
# Try to open the linked file in a new Blender instance
try:
# Use Blender's built-in file browser to open the file
bpy.ops.wm.path_open(filepath=self.filepath)
self.report({'INFO'}, f"Opening linked file: {self.filepath}")
except Exception as e:
self.report({'ERROR'}, f"Failed to open linked file: {e}")
return {'CANCELLED'}
return {'FINISHED'}
class DLM_OT_add_search_path(Operator):
"""Add a new search path for missing libraries"""
bl_idname = "dlm.add_search_path"
bl_label = "Add Search Path"
bl_options = {'REGISTER'}
def execute(self, context):
prefs = context.preferences.addons.get(__package__)
if prefs:
new_path = prefs.preferences.search_paths.add()
new_path.path = "//" # Default to relative path
self.report({'INFO'}, f"Added search path: {new_path.path}")
return {'FINISHED'}
class DLM_OT_remove_search_path(Operator):
"""Remove a search path"""
bl_idname = "dlm.remove_search_path"
bl_label = "Remove Search Path"
bl_options = {'REGISTER'}
index: IntProperty(
name="Index",
description="Index of the search path to remove",
default=0
)
def execute(self, context):
prefs = context.preferences.addons.get(__package__)
if prefs and prefs.preferences.search_paths:
if 0 <= self.index < len(prefs.preferences.search_paths):
prefs.preferences.search_paths.remove(self.index)
self.report({'INFO'}, f"Removed search path at index {self.index}")
else:
self.report({'ERROR'}, f"Invalid index: {self.index}")
return {'FINISHED'}
class DLM_OT_attempt_relink(Operator):
"""Attempt to relink missing libraries using search paths"""
bl_idname = "dlm.attempt_relink"
bl_label = "Attempt Relink"
bl_options = {'REGISTER'}
def execute(self, context):
prefs = context.preferences.addons.get(__package__)
if not prefs or not prefs.preferences.search_paths:
self.report({'ERROR'}, "No search paths configured. Add search paths in addon preferences.")
return {'CANCELLED'}
# Get missing libraries
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
if not missing_libs:
self.report({'INFO'}, "No missing libraries to relink")
return {'FINISHED'}
self.report({'INFO'}, f"Attempting to relink {len(missing_libs)} missing libraries...")
# Scan search paths for missing libraries (FMT-inspired approach)
files_dir_list = []
try:
for search_path in prefs.preferences.search_paths:
if search_path.path:
for dirpath, dirnames, filenames in os.walk(bpy.path.abspath(search_path.path)):
files_dir_list.append([dirpath, filenames])
except FileNotFoundError:
self.report({'ERROR'}, f"Error - Bad file path in search paths")
return {'CANCELLED'}
# Try to find and relink each missing library (FMT-style)
relinked_count = 0
indirect_errors = []
for lib_item in missing_libs:
lib_filename = os.path.basename(lib_item.filepath)
found = False
# Search through all directories
for dir_info in files_dir_list:
dirpath, filenames = dir_info
# Look for exact filename match
if lib_filename in filenames:
new_path = os.path.join(dirpath, lib_filename)
try:
# Try to relink using Blender's system
# This will naturally detect indirect links if they exist
bpy.ops.file.find_missing_files()
found = True
relinked_count += 1
break
except Exception as e:
error_msg = str(e)
if "unable to relocate indirectly linked library" in error_msg:
indirect_errors.append(lib_item.filepath)
print(f"Indirect link detected for: {lib_item.filepath}")
else:
print(f"Error relinking {lib_item.filepath}: {e}")
if found:
break
if not found:
print(f"Could not find {lib_filename} in search paths")
# Update the UI to show indirect links
if indirect_errors:
self.report({'WARNING'}, f"Found {len(indirect_errors)} indirectly linked libraries")
# Mark these as having indirect missing
for lib_item in context.scene.dynamic_link_manager.linked_libraries:
if lib_item.filepath in indirect_errors:
lib_item.has_indirect_missing = True
lib_item.indirect_missing_count = 1
self.report({'INFO'}, f"Relink attempt complete. Relinked: {relinked_count}, Indirect: {len(indirect_errors)}")
return {'FINISHED'}
class DLM_OT_find_in_folders(Operator):
"""Find missing libraries in search folders and subfolders"""
bl_idname = "dlm.find_in_folders"
bl_label = "Find in Folders"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
prefs = context.preferences.addons.get(__package__)
if not prefs or not prefs.preferences.search_paths:
self.report({'ERROR'}, "No search paths configured. Add search paths in addon preferences.")
return {'CANCELLED'}
# Get missing libraries
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
if not missing_libs:
self.report({'INFO'}, "No missing libraries to find")
return {'FINISHED'}
self.report({'INFO'}, f"Searching for {len(missing_libs)} missing libraries...")
# FMT-style directory scanning
files_dir_list = []
try:
for search_path in prefs.preferences.search_paths:
if search_path.path:
for dirpath, dirnames, filenames in os.walk(bpy.path.abspath(search_path.path)):
files_dir_list.append([dirpath, filenames])
except FileNotFoundError:
self.report({'ERROR'}, f"Error - Bad file path in search paths")
return {'CANCELLED'}
# Try to find each missing library (FMT-style)
found_count = 0
for lib_item in missing_libs:
lib_filename = os.path.basename(lib_item.filepath)
found = False
# Search through all directories
for dir_info in files_dir_list:
dirpath, filenames = dir_info
# Look for exact filename match
if lib_filename in filenames:
new_path = os.path.join(dirpath, lib_filename)
self.report({'INFO'}, f"Found {lib_filename} at: {new_path}")
found_count += 1
found = True
break
if found:
break
if not found:
self.report({'WARNING'}, f"Could not find {lib_filename} in search paths")
if found_count > 0:
self.report({'INFO'}, f"Found {found_count} libraries in search paths")
else:
self.report({'WARNING'}, "No libraries found in search paths")
return {'FINISHED'}
class DLM_OT_browse_search_path(Operator):
"""Browse for a search path directory"""
bl_idname = "dlm.browse_search_path"
bl_label = "Browse Search Path"
bl_options = {'REGISTER'}
index: IntProperty(
name="Index",
description="Index of the search path to browse for",
default=0
)
filepath: StringProperty(
name="Search Path",
description="Path to search for missing linked libraries",
subtype='DIR_PATH'
)
def execute(self, context):
prefs = context.preferences.addons.get(__package__)
if prefs and prefs.preferences.search_paths:
if 0 <= self.index < len(prefs.preferences.search_paths):
prefs.preferences.search_paths[self.index].path = self.filepath
self.report({'INFO'}, f"Updated search path {self.index + 1}: {self.filepath}")
else:
self.report({'ERROR'}, f"Invalid index: {self.index}")
return {'FINISHED'}
def invoke(self, context, event):
prefs = context.preferences.addons.get(__package__)
if prefs and prefs.preferences.search_paths:
if 0 <= self.index < len(prefs.preferences.search_paths):
# Set the current path as default
self.filepath = prefs.preferences.search_paths[self.index].path
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def register():
bpy.utils.register_class(DLM_OT_replace_linked_asset)
bpy.utils.register_class(DLM_OT_scan_linked_assets)
bpy.utils.register_class(DLM_OT_open_linked_file)
bpy.utils.register_class(DLM_OT_add_search_path)
bpy.utils.register_class(DLM_OT_remove_search_path)
bpy.utils.register_class(DLM_OT_browse_search_path)
bpy.utils.register_class(DLM_OT_attempt_relink)
bpy.utils.register_class(DLM_OT_find_in_folders)
bpy.utils.register_class(DLM_OT_fmt_style_find)
def unregister():
bpy.utils.unregister_class(DLM_OT_fmt_style_find)
bpy.utils.unregister_class(DLM_OT_find_in_folders)
bpy.utils.unregister_class(DLM_OT_attempt_relink)
bpy.utils.unregister_class(DLM_OT_browse_search_path)
bpy.utils.unregister_class(DLM_OT_remove_search_path)
bpy.utils.unregister_class(DLM_OT_add_search_path)
bpy.utils.unregister_class(DLM_OT_open_linked_file)
bpy.utils.unregister_class(DLM_OT_scan_linked_assets)
bpy.utils.unregister_class(DLM_OT_replace_linked_asset)
@@ -0,0 +1,6 @@
# 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.
"""Feature logic (migrator, library) for Dynamic Link Manager."""
@@ -0,0 +1,207 @@
# 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.
import os
import re
import bpy
def _is_file_missing(filepath):
if not filepath:
return True
try:
abs_path = bpy.path.abspath(filepath)
except Exception:
abs_path = filepath
return not os.path.isfile(abs_path)
def _get_library_name(filepath):
return os.path.basename(filepath) if filepath else "Unknown"
def scan_linked_assets(context, report):
props = context.scene.dynamic_link_manager
props.linked_libraries.clear()
for lib in bpy.data.libraries:
try:
if lib.filepath:
lib.reload()
except Exception:
pass
direct_libs = set()
for lib in bpy.data.libraries:
try:
if getattr(lib, "parent", None) is None and lib.filepath:
direct_libs.add(lib.filepath)
except Exception:
continue
all_libraries = set(direct_libs)
props.linked_assets_count = len(all_libraries)
missing_indirect_libs = set()
for lib in bpy.data.libraries:
try:
if getattr(lib, "parent", None) is not None and lib.filepath:
try:
abs_child = bpy.path.abspath(lib.filepath)
except Exception:
abs_child = lib.filepath
if not os.path.isfile(abs_child):
root = lib.parent
while getattr(root, "parent", None) is not None:
root = root.parent
if root and root.filepath:
missing_indirect_libs.add(root.filepath)
except Exception:
continue
missing_ids_by_library = set()
for idb in list(bpy.data.objects) + list(bpy.data.meshes) + list(bpy.data.armatures) + list(bpy.data.materials) + list(bpy.data.node_groups) + list(bpy.data.images) + list(bpy.data.texts) + list(bpy.data.collections) + list(bpy.data.cameras) + list(bpy.data.lights):
try:
lib = getattr(idb, "library", None)
if lib and lib.filepath and getattr(idb, "is_library_missing", False):
missing_ids_by_library.add(lib.filepath)
except Exception:
continue
library_items = []
for filepath in sorted(all_libraries):
if not filepath:
continue
lib_item = props.linked_libraries.add()
lib_item.filepath = filepath
lib_item.name = _get_library_name(filepath)
lib_item.is_missing = _is_file_missing(filepath)
lib_item.is_indirect = (filepath in missing_indirect_libs) or (filepath in missing_ids_by_library)
library_items.append((lib_item, filepath))
library_items.sort(key=lambda x: (not x[0].is_missing, _get_library_name(x[1]).lower()))
props.linked_libraries.clear()
for lib_item, filepath in library_items:
new_item = props.linked_libraries.add()
new_item.filepath = filepath
new_item.name = _get_library_name(filepath)
new_item.is_missing = lib_item.is_missing
new_item.is_indirect = lib_item.is_indirect
report({"INFO"}, f"Found {len(all_libraries)} unique linked library files")
return {"FINISHED"}
def find_libraries_in_folders(context, report, addon_name=None):
if addon_name is None:
addon_name = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
prefs = context.preferences.addons.get(addon_name)
if not prefs or not prefs.preferences.search_paths:
report({"ERROR"}, "No search paths configured. Add search paths in addon preferences.")
return {"CANCELLED"}
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
if not missing_libs:
report({"INFO"}, "No missing libraries to find")
return {"FINISHED"}
report({"INFO"}, f"Searching for {len(missing_libs)} missing libraries in search paths...")
files_dir_list = []
total_dirs_scanned = 0
try:
for search_path in prefs.preferences.search_paths:
if not search_path.path:
continue
abs_path = bpy.path.abspath(search_path.path) if search_path.path.startswith("//") else search_path.path
report({"INFO"}, f"Scanning search path: {abs_path}")
if not os.path.exists(abs_path):
report({"WARNING"}, f"Search path does not exist: {abs_path}")
continue
if not os.path.isdir(abs_path):
report({"WARNING"}, f"Search path is not a directory: {abs_path}")
continue
for dirpath, dirnames, filenames in os.walk(abs_path):
files_dir_list.append([dirpath, filenames])
total_dirs_scanned += 1
if total_dirs_scanned > 1000:
report({"WARNING"}, "Reached scan limit of 1000 directories.")
break
except Exception as e:
report({"ERROR"}, f"Error scanning search paths: {e}")
return {"CANCELLED"}
found_libraries = {}
for lib_item in missing_libs:
lib_filename = os.path.basename(lib_item.filepath)
for dirpath, filenames in files_dir_list:
if lib_filename in filenames:
found_libraries[lib_filename] = os.path.join(dirpath, lib_filename)
report({"INFO"}, f"Found {lib_filename} at: {os.path.join(dirpath, lib_filename)}")
break
if found_libraries:
relinked_count = 0
for lib in bpy.data.libraries:
try:
if not lib.filepath:
continue
lib_filename = os.path.basename(lib.filepath)
if lib_filename in found_libraries:
new_path = found_libraries[lib_filename]
current_abs = bpy.path.abspath(lib.filepath)
if not os.path.isfile(current_abs) or current_abs != new_path:
lib.filepath = new_path
try:
lib.reload()
except Exception:
pass
relinked_count += 1
report({"INFO"}, f"Relinked {lib_filename} -> {new_path}")
except Exception:
continue
report({"INFO"}, f"Manually relinked {relinked_count} libraries")
else:
report({"WARNING"}, "No libraries found in search paths")
try:
bpy.ops.dlm.scan_linked_assets()
except Exception:
pass
report({"INFO"}, "Operation complete.")
return {"FINISHED"}
def attempt_relink(context, report, addon_name=None):
if addon_name is None:
addon_name = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
prefs = context.preferences.addons.get(addon_name)
if not prefs or not prefs.preferences.search_paths:
report({"ERROR"}, "No search paths configured.")
return {"CANCELLED"}
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
if not missing_libs:
report({"INFO"}, "No missing libraries to relink")
return {"FINISHED"}
report({"INFO"}, f"Attempting to relink {len(missing_libs)} missing libraries...")
files_dir_list = []
try:
for search_path in prefs.preferences.search_paths:
if search_path.path:
for dirpath, dirnames, filenames in os.walk(bpy.path.abspath(search_path.path)):
files_dir_list.append([dirpath, filenames])
except FileNotFoundError:
report({"ERROR"}, "Bad file path in search paths")
return {"CANCELLED"}
relinked_count = 0
for lib_item in missing_libs:
lib_filename = os.path.basename(lib_item.filepath)
for dirpath, filenames in files_dir_list:
if lib_filename in filenames:
try:
bpy.ops.file.find_missing_files()
relinked_count += 1
except Exception:
pass
break
report({"INFO"}, f"Relink attempt complete. Relinked: {relinked_count}")
return {"FINISHED"}
@@ -0,0 +1,657 @@
# 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.
"""Character migrator: migrate animation, constraints, relations from original to replacement armature."""
import bpy
from ..utils import descendants, collection_containing_armature
def get_pair_manual(context):
"""Return (orig_armature, rep_armature) from scene props, or (None, None)."""
props = getattr(context.scene, "dynamic_link_manager", None)
if not props:
return None, None
orig = getattr(props, "original_character", None)
rep = getattr(props, "replacement_character", None)
if orig and orig.type == "ARMATURE" and rep and rep.type == "ARMATURE":
return orig, rep
return None, None
def get_pair_automatic(context):
"""Discover one pair by convention: Name_Rigify and Name_Rigify.001. Returns (orig, rep) or (None, None)."""
pairs = []
for obj in bpy.data.objects:
if obj.type != "ARMATURE":
continue
name = obj.name
if name.endswith("_Rigify.001"):
base = name[:-len("_Rigify.001")]
orig = bpy.data.objects.get(f"{base}_Rigify")
if orig and orig.type == "ARMATURE" and orig != obj:
pairs.append((orig, obj))
return pairs[0] if pairs else (None, None)
def run_copy_attr(orig, rep):
"""Copy armature object attributes: location, rotation, scale (CopyAttr)."""
rep.location = orig.location.copy()
if orig.rotation_mode == "QUATERNION":
rep.rotation_quaternion = orig.rotation_quaternion.copy()
else:
rep.rotation_euler = orig.rotation_euler.copy()
rep.scale = orig.scale.copy()
def _has_als_anywhere(orig):
"""Return True if orig has Animation Layers (addon uses Object.als RNA PropertyGroup, obj.als.turn_on)."""
# Animation Layers addon: bpy.types.Object.als (RNA), not id props
if getattr(orig, "als", None) is not None:
return True
key = "als.turn_on"
if key in orig:
return True
if getattr(orig, "data", None) and hasattr(orig.data, "keys") and key in orig.data:
return True
try:
als = orig.get("als")
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
return True
except Exception:
pass
for pb in orig.pose.bones:
if key in pb:
return True
try:
als = pb.get("als")
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
return True
except Exception:
pass
return False
def _debug_als_lookup(orig):
"""Print full debug for AnimLayers: RNA obj.als and every id_prop on orig."""
key = "als.turn_on"
print("[DLM MigNLA] === AnimLayers debug ===")
als_rna = getattr(orig, "als", None)
print(f"[DLM MigNLA] orig.als (RNA): {als_rna!r}, turn_on={getattr(als_rna, 'turn_on', 'N/A') if als_rna else 'N/A'}")
print(f"[DLM MigNLA] 'als.turn_on' in orig (object): {key in orig}")
if getattr(orig, "data", None):
has_data_keys = hasattr(orig.data, "keys")
print(f"[DLM MigNLA] orig.data has keys(): {has_data_keys}")
if has_data_keys:
print(f"[DLM MigNLA] 'als.turn_on' in orig.data: {key in orig.data}")
print(f"[DLM MigNLA] orig.data keys: {list(orig.data.keys())}")
try:
als = orig.get("als")
has_als = als is not None and callable(getattr(als, "keys", None))
print(f"[DLM MigNLA] orig.get('als') is group: {has_als}")
if has_als:
print(f"[DLM MigNLA] orig['als'] keys: {list(als.keys())}")
print(f"[DLM MigNLA] 'turn_on' in orig['als']: {'turn_on' in als.keys()}")
except Exception as e:
print(f"[DLM MigNLA] orig.get('als') error: {e}")
print(f"[DLM MigNLA] orig (object) all keys: {list(orig.keys())}")
for k in list(orig.keys()):
try:
v = orig[k]
if callable(getattr(v, "keys", None)):
print(f"[DLM MigNLA] orig[{k!r}] (group) keys: {list(v.keys())}")
else:
print(f"[DLM MigNLA] orig[{k!r}] = {v!r}")
except Exception as e:
print(f"[DLM MigNLA] orig[{k!r}] error: {e}")
# RNA props that might be animation-layer related
try:
rna_props = list(orig.bl_rna.properties.keys())
layer_like = [p for p in rna_props if "layer" in p.lower() or "als" in p.lower() or "turn" in p.lower() or "anim" in p.lower()]
print(f"[DLM MigNLA] orig RNA props (layer/als/turn/anim): {layer_like}")
except Exception as e:
print(f"[DLM MigNLA] orig bl_rna.properties error: {e}")
# Every bone that has keys
bones_with_keys = []
for pb in orig.pose.bones:
if pb.keys():
bones_with_keys.append((pb.name, list(pb.keys())))
print(f"[DLM MigNLA] bones with id_props ({len(bones_with_keys)}): {bones_with_keys[:20]}{'...' if len(bones_with_keys) > 20 else ''}")
for bname, bkeys in bones_with_keys[:10]:
pb = orig.pose.bones[bname]
print(f"[DLM MigNLA] bone {bname!r}: keys={bkeys}")
for k in bkeys:
try:
v = pb[k]
if callable(getattr(v, "keys", None)):
print(f"[DLM MigNLA] [{k!r}] (group) keys: {list(v.keys())}")
else:
print(f"[DLM MigNLA] [{k!r}] = {v!r}")
except Exception as e:
print(f"[DLM MigNLA] [{k!r}] error: {e}")
print("[DLM MigNLA] === end AnimLayers debug ===")
def _mirror_als_turn_on(orig, rep):
"""Mirror Animation Layers state: obj.als.turn_on (RNA) and id-property fallbacks."""
# Animation Layers addon: Object.als is RNA PropertyGroup
orig_als = getattr(orig, "als", None)
rep_als = getattr(rep, "als", None)
if orig_als is not None and rep_als is not None:
try:
rep_als.turn_on = orig_als.turn_on
except Exception:
pass
key = "als.turn_on"
if key in orig:
try:
rep[key] = orig[key]
except Exception:
pass
try:
als = orig.get("als")
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
if "als" not in rep:
rep["als"] = {}
rep["als"]["turn_on"] = als["turn_on"]
except Exception:
pass
if getattr(orig, "data", None) and hasattr(orig.data, "keys") and key in orig.data:
try:
if rep.data is not None and hasattr(rep.data, "keys"):
rep.data[key] = orig.data[key]
except Exception:
pass
for pbone in orig.pose.bones:
if pbone.name not in rep.pose.bones:
continue
rbone = rep.pose.bones[pbone.name]
if key in pbone:
try:
rbone[key] = pbone[key]
except Exception:
pass
try:
als = pbone.get("als")
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
if "als" not in rbone:
rbone["als"] = {}
rbone["als"]["turn_on"] = als["turn_on"]
except Exception:
pass
def run_mig_nla(orig, rep, report=None):
"""Migrate NLA: copy tracks and strips to replacement; or mirror action slot when no NLA (MigNLA)."""
if not orig.animation_data:
return
ad = orig.animation_data
has_nla = ad.nla_tracks and len(ad.nla_tracks) > 0
active_action = getattr(ad, "action", None)
if not has_nla:
if rep.animation_data is None:
rep.animation_data_create()
rad = rep.animation_data
# Debug: Orig action slot state (Blender 4.4+ slotted actions).
def _slot_debug(label, animdata):
if animdata is None:
print(f"[DLM MigNLA] {label}: no animation_data")
return
a = getattr(animdata, "action", None)
print(f"[DLM MigNLA] {label} action={a.name if a else None}")
for p in ("action_slot", "action_slot_handle", "last_slot_identifier",
"action_blend_type", "action_extrapolation", "action_influence"):
if hasattr(animdata, p):
v = getattr(animdata, p, None)
if hasattr(v, "identifier"):
v = getattr(v, "identifier", v)
print(f"[DLM MigNLA] {p}={v!r}")
_slot_debug("Orig (before)", ad)
_slot_debug("Rep (before)", rad)
# 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
# Copy Action Slot and related props (Blender 4.4+ slotted actions).
if getattr(ad, "action_slot", None) and getattr(rad, "action_slot", None):
try:
rad.action_slot = ad.action_slot
print(f"[DLM MigNLA] set rep action_slot={getattr(ad.action_slot, 'identifier', ad.action_slot)!r}")
except Exception as e:
print(f"[DLM MigNLA] rad.action_slot assign failed: {e}")
for prop in ("action_blend_type", "action_extrapolation", "action_influence"):
if hasattr(ad, prop) and hasattr(rad, prop):
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)
if report:
report({"INFO"}, "No NLA detected, active action and slot copied to Replacement Armature.")
return
if rep.animation_data is None:
rep.animation_data_create()
rep_tracks = rep.animation_data.nla_tracks
existing_names = {t.name for t in rep_tracks}
prev_track = rep_tracks[-1] if rep_tracks else None
for track in ad.nla_tracks:
new_track = rep_tracks.new(prev=prev_track)
name = track.name
if name in existing_names:
base, n = name, 1
while f"{base}.{n:03d}" in existing_names:
n += 1
name = f"{base}.{n:03d}"
new_track.name = name
existing_names.add(name)
new_track.mute = track.mute
new_track.is_solo = track.is_solo
new_track.lock = track.lock
for strip in track.strips:
if strip.type != "CLIP" or not strip.action:
continue
new_strip = new_track.strips.new(
strip.name, int(strip.frame_start), strip.action
)
new_strip.blend_type = strip.blend_type
new_strip.extrapolation = strip.extrapolation
new_strip.frame_end = strip.frame_end
new_strip.blend_in = strip.blend_in
new_strip.blend_out = strip.blend_out
new_strip.repeat = strip.repeat
new_strip.action_frame_start = strip.action_frame_start
new_strip.action_frame_end = strip.action_frame_end
new_strip.influence = strip.influence
new_strip.mute = strip.mute
new_strip.scale = strip.scale
new_strip.use_auto_blend = strip.use_auto_blend
new_strip.use_reverse = strip.use_reverse
new_strip.use_animated_influence = strip.use_animated_influence
new_strip.use_animated_time = strip.use_animated_time
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)
if report:
_debug_als_lookup(orig)
has_als = _has_als_anywhere(orig)
print(f"[DLM MigNLA] AnimLayers check: has_als={has_als}")
if has_als:
report({"INFO"}, "NLA layers detected, Animation Layer attributes migrated to Replacement Armature.")
else:
report({"INFO"}, "NLA layers detected and migrated. No Animation Layers found.")
EXCLUDE_PROPS = {"_RNA_UI", "rigify_type", "rigify_parameters"}
def _is_id_prop_group(val):
"""True if val is an ID property group (nested dict-like), not a leaf or string."""
if val is None or isinstance(val, (str, bytes)):
return False
return callable(getattr(val, "keys", None))
def _copy_id_prop_recursive(orig_container, rep_container, key, debug_path="", debug=False):
"""Copy one id property from orig_container[key] into rep_container[key] (recursive for groups)."""
if key not in orig_container:
return
orig_val = orig_container[key]
try:
if _is_id_prop_group(orig_val):
if key not in rep_container:
rep_container[key] = {}
rep_group = rep_container[key]
for k in list(orig_val.keys()):
_copy_id_prop_recursive(orig_val, rep_group, k, f"{debug_path}.{key}", debug)
if debug:
print(f"[DLM MigCustProps] group {debug_path}.{key!r}: copied {len(orig_val.keys())} sub-keys")
else:
rep_container[key] = orig_val
if debug:
print(f"[DLM MigCustProps] leaf {debug_path}.{key!r} = {orig_val!r}")
except Exception as e:
print(f"[DLM MigCustProps] FAILED {debug_path}.{key!r}: {e}")
def _copy_custom_props_from(orig_obj, rep_obj, debug_label="", debug=False):
"""Copy all custom props from orig_obj to rep_obj (object or pose bone), including nested groups."""
keys = [k for k in orig_obj.keys() if k not in EXCLUDE_PROPS]
if debug and keys:
print(f"[DLM MigCustProps] {debug_label} keys: {keys}")
for key in keys:
_copy_id_prop_recursive(orig_obj, rep_obj, key, debug_label, debug)
def run_mig_cust_props(orig, rep):
"""Custom properties: copy overridden settings (ID props only, incl. nested e.g. Settings/Devices) from orig to rep."""
debug = True
print(f"[DLM MigCustProps] orig={orig.name!r} rep={rep.name!r}")
# Armature object
o_keys = list(orig.keys())
print(f"[DLM MigCustProps] armature orig keys (all): {o_keys}")
_copy_custom_props_from(orig, rep, f"obj:{orig.name}", debug)
# Bones with any id props
bones_with_keys = [(pb.name, list(pb.keys())) for pb in orig.pose.bones if pb.keys()]
print(f"[DLM MigCustProps] bones with id_props: {bones_with_keys}")
for pbone in orig.pose.bones:
if pbone.name not in rep.pose.bones:
continue
rbone = rep.pose.bones[pbone.name]
_copy_custom_props_from(pbone, rbone, f"bone:{pbone.name}", debug)
# After: rep armature and Settings bone if present
print(f"[DLM MigCustProps] rep armature keys after: {list(rep.keys())}")
if "Settings" in rep.pose.bones:
sb = rep.pose.bones["Settings"]
print(f"[DLM MigCustProps] rep bone Settings keys after: {list(sb.keys())}")
if sb.keys():
for k in sb.keys():
v = sb[k]
if _is_id_prop_group(v):
print(f"[DLM MigCustProps] Settings[{k!r}] (group) keys: {list(v.keys())}")
else:
print(f"[DLM MigCustProps] Settings[{k!r}] = {v!r}")
def _retarget_id(ob, orig, rep, orig_to_rep):
"""Return rep, orig_to_rep[ob], or ob so constraint targets point to replacement when appropriate."""
if ob is None:
return None
if ob == orig:
return rep
return orig_to_rep.get(ob, ob)
def _copy_constraint_props(c, nc, orig, rep, orig_to_rep):
"""Copy all copyable RNA properties from c to nc, retargeting object/armature pointers."""
for rna_prop in c.bl_rna.properties:
if rna_prop.is_readonly or rna_prop.identifier in ("name", "type"):
continue
if not hasattr(nc, rna_prop.identifier):
continue
try:
val = getattr(c, rna_prop.identifier)
except Exception:
continue
rna_type = getattr(rna_prop, "type", None)
if rna_type == "POINTER":
setattr(nc, rna_prop.identifier, _retarget_id(val, orig, rep, orig_to_rep))
elif rna_type == "COLLECTION":
# e.g. ArmatureConstraint.targets: ensure count then copy item props (target, subtarget, weight)
try:
dst_coll = getattr(nc, rna_prop.identifier)
src_coll = getattr(c, rna_prop.identifier)
add_fn = getattr(dst_coll, "add", None) or getattr(dst_coll, "new", None)
for i in range(len(src_coll)):
if i >= len(dst_coll) and add_fn:
add_fn()
for i, src_item in enumerate(src_coll):
if i >= len(dst_coll):
break
dst_item = dst_coll[i]
for p in dst_item.bl_rna.properties:
if p.is_readonly or p.identifier == "name":
continue
if not hasattr(dst_item, p.identifier):
continue
try:
v = getattr(src_item, p.identifier)
if getattr(p, "type", None) == "POINTER":
v = _retarget_id(v, orig, rep, orig_to_rep)
setattr(dst_item, p.identifier, v)
except Exception:
pass
except Exception:
pass
else:
try:
setattr(nc, rna_prop.identifier, val)
except Exception:
pass
def run_mig_bone_const(orig, rep, orig_to_rep):
"""Bone constraints: remove stale on rep, copy from orig with full props (targets, etc.) and retarget, trim duplicates."""
other_originals = [o for o in orig_to_rep if o != orig]
for pb in rep.pose.bones:
to_remove = [c for c in pb.constraints if getattr(c, "target", None) in other_originals]
for c in to_remove:
pb.constraints.remove(c)
for pbone in orig.pose.bones:
if pbone.name not in rep.pose.bones:
continue
rbone = rep.pose.bones[pbone.name]
for c in pbone.constraints:
nc = rbone.constraints.new(type=c.type)
nc.name = c.name
_copy_constraint_props(c, nc, orig, rep, orig_to_rep)
for pb in orig.pose.bones:
if pb.name not in rep.pose.bones:
continue
ro, rr = pb.constraints, rep.pose.bones[pb.name].constraints
while len(rr) > len(ro):
rr.remove(rr[-1])
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)."""
orig_hierarchy = {orig} | descendants(orig)
candidates = set(rep_descendants)
for ob in bpy.data.objects:
if getattr(ob.parent, "name", None) == orig.name:
candidates.add(ob)
for c in getattr(ob, "constraints", []):
if getattr(c, "target", None) == orig:
candidates.add(ob)
candidates -= orig_hierarchy
for ob in candidates:
if ob.parent == orig:
ob.parent = rep
for c in getattr(ob, "constraints", []):
if getattr(c, "target", None) == orig:
c.target = rep
if ob.type == "MESH" and ob.modifiers:
for m in ob.modifiers:
if getattr(m, "object", None) == orig:
m.object = rep
def _base_body_name_match(ob):
"""True if object looks like the base body mesh (MESH, name has body+base)."""
if ob.type != "MESH":
return False
name_lower = (ob.name + " " + (ob.data.name if ob.data else "")).lower()
return "body" in name_lower and "base" in name_lower
def _objects_in_collection_recursive(coll):
"""Yield all objects in collection and nested collections."""
for ob in coll.objects:
yield ob
for child in coll.children:
yield from _objects_in_collection_recursive(child)
def _find_base_body(armature, descendants_iter, rep_base_name=None):
"""Return the base body mesh: in descendants (armature mod), or in armature's collection(s), matched by name."""
def gather_candidates(ob_iter):
candidates = []
for ob in ob_iter:
if not _base_body_name_match(ob):
continue
if ob.modifiers:
for m in ob.modifiers:
if m.type == "ARMATURE" and m.object == armature:
return ob, candidates
candidates.append(ob)
return None, candidates
found, candidates = gather_candidates(descendants_iter)
if found:
return found
# Fallback: base body may be in same collection as armature but not parented to it (e.g. linked).
if not candidates:
for coll in [collection_containing_armature(armature)] + list(getattr(armature, "users_collection", []) or []):
if not coll:
continue
found, candidates = gather_candidates(_objects_in_collection_recursive(coll))
if found:
return found
if candidates:
break
if not candidates:
return None
if rep_base_name:
base = rep_base_name.rsplit(".", 1)[0] if "." in rep_base_name else rep_base_name
for ob in candidates:
if ob.name == base or ob.name.startswith(base + ".") or (ob.data and ob.data.name == base):
return ob
return candidates[0]
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."""
orig_descendants = list(descendants(orig))
for ob in list(rep_descendants):
if not _base_body_name_match(ob):
continue
if ob.modifiers:
for m in ob.modifiers:
if m.type == "ARMATURE" and m.object == rep:
break
else:
continue
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:
# 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
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_full_migration(context):
"""
Run the full 7-step character migration for the single pair (manual or automatic).
Returns (True, message) on success, (False, error_message) on failure.
"""
props = getattr(context.scene, "dynamic_link_manager", None)
use_auto = props and getattr(props, "migrator_mode", False)
orig, rep = (get_pair_automatic(context) if use_auto else get_pair_manual(context))
if not orig or not rep:
return False, "No character pair (set Original/Replacement or enable Automatic)."
if orig == rep:
return False, "Original and replacement must be different armatures."
orig_to_rep = {orig: rep}
rep_descendants = descendants(rep)
try:
run_copy_attr(orig, rep)
run_mig_nla(orig, rep)
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)
run_mig_bbody_shapekeys(orig, rep, rep_descendants, context)
except Exception as e:
return False, str(e)
return True, f"Migrated {orig.name}{rep.name}"
@@ -0,0 +1,146 @@
# 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.
"""Tweak tools: add/remove/bake COPY_TRANSFORMS on Rigify arm/leg tweak bones."""
import bpy
# Rigify-style tweak bone names (only those present on armature are used)
ARM_TWEAK_BONES = (
"upper_arm_tweak.L", "upper_arm_tweak.R",
"forearm_tweak.L", "forearm_tweak.R",
"hand_tweak.L", "hand_tweak.R",
)
LEG_TWEAK_BONES = (
"thigh_tweak.L", "thigh_tweak.R",
"shin_tweak.L", "shin_tweak.R",
"foot_tweak.L", "foot_tweak.R",
)
TWEAK_CONSTRAINT_NAME = "Copy from Original"
def get_tweak_bones(armature, limb):
"""Return list of tweak bone names that exist on armature. limb in 'arm', 'leg', 'both'."""
if not armature or armature.type != "ARMATURE" or not armature.pose:
return []
bones = armature.pose.bones
if limb == "arm":
names = ARM_TWEAK_BONES
elif limb == "leg":
names = LEG_TWEAK_BONES
elif limb == "both":
names = ARM_TWEAK_BONES + LEG_TWEAK_BONES
else:
return []
return [n for n in names if n in bones]
def add_tweak_constraints(orig, rep, limb):
"""On rep, add COPY_TRANSFORMS on each tweak bone targeting same bone on orig."""
names = get_tweak_bones(rep, limb)
for name in names:
pb = rep.pose.bones[name]
c = pb.constraints.new(type="COPY_TRANSFORMS")
c.name = TWEAK_CONSTRAINT_NAME
c.target = orig
c.subtarget = name
c.mix_mode = "REPLACE"
def remove_tweak_constraints(orig, rep, limb):
"""On rep, remove COPY_TRANSFORMS that target orig from tweak bones."""
names = get_tweak_bones(rep, limb)
removed = 0
for name in names:
pb = rep.pose.bones[name]
to_remove = [
c for c in pb.constraints
if c.type == "COPY_TRANSFORMS" and getattr(c, "target", None) == orig
]
for c in to_remove:
pb.constraints.remove(c)
removed += 1
return removed
def _frame_range_from_track(rep, track_name):
"""Return (frame_start, frame_end) from rep's NLA track named track_name, or None."""
if not track_name or not rep.animation_data or not rep.animation_data.nla_tracks:
return None
track = rep.animation_data.nla_tracks.get(track_name)
if not track or not track.strips:
return None
start = min(s.frame_start for s in track.strips)
end = max(s.frame_end for s in track.strips)
return (int(start), int(end))
def bake_tweak_constraints(context, orig, rep, limb, track_name, post_clean):
"""
Select rep's tweak bones, run nla.bake, optionally run clean + decimate.
Returns (True, message) or (False, error_message).
"""
names = get_tweak_bones(rep, limb)
if not names:
return False, f"No tweak bones found for {limb} on {rep.name}"
scene = context.scene
frame_range = _frame_range_from_track(rep, track_name) if track_name else None
if not frame_range:
frame_range = (scene.frame_start, 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")
# Select only tweak bones on rep
bpy.ops.pose.select_all(action="DESELECT")
for name in names:
rep.pose.bones[name].bone.select = True
# Bake
try:
bpy.ops.nla.bake(
frame_start=frame_start,
frame_end=frame_end,
step=1,
only_selected=True,
visual_keying=True,
clear_constraints=True,
clear_parents=True,
use_current_action=True,
clean_curves=False,
bake_types={"POSE"},
channel_types={"LOCATION", "ROTATION"},
)
except Exception as e:
return False, str(e)
if not post_clean:
return True, f"Baked {len(names)} tweak bones ({frame_start}-{frame_end})."
# Post-clean: find an area we can use for action/graph ops
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(names)} tweak bones ({frame_start}-{frame_end})."
@@ -1,263 +0,0 @@
import bpy
from bpy.types import Panel, PropertyGroup, AddonPreferences, UIList
from bpy.props import IntProperty, StringProperty, BoolProperty, CollectionProperty
# Properties for search paths
class SearchPathItem(PropertyGroup):
path: StringProperty(
name="Search Path",
description="Path to search for missing linked libraries",
subtype='DIR_PATH'
)
# Properties for individual linked datablocks
class LinkedDatablockItem(PropertyGroup):
name: StringProperty(name="Name", description="Name of the linked datablock")
type: StringProperty(name="Type", description="Type of the linked datablock")
# FMT-style UIList for linked libraries
class DYNAMICLINK_UL_library_list(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
custom_icon = 'FILE_BLEND'
if self.layout_type in {'DEFAULT', 'COMPACT'}:
# Library name and status (FMT-style layout)
layout.scale_x = 0.4
layout.label(text=item.name)
# Status indicator
layout.scale_x = 0.3
if item.is_missing:
layout.label(text="MISSING", icon='ERROR')
elif item.has_indirect_missing:
layout.label(text="INDIRECT", icon='ERROR')
else:
layout.label(text="OK", icon='FILE_BLEND')
# File path (FMT-style truncated)
layout.scale_x = 0.3
path_text = item.filepath
if len(path_text) > 30:
path_text = "..." + path_text[-27:]
layout.label(text=path_text, icon='FILE_FOLDER')
elif self.layout_type in {'GRID'}:
layout.alignment = 'CENTER'
layout.label(text="", icon=custom_icon)
# Properties for a single linked library file
class LinkedLibraryItem(PropertyGroup):
filepath: StringProperty(name="File Path", description="Path to the linked .blend file")
name: StringProperty(name="Name", description="Name of the linked .blend file")
is_missing: BoolProperty(name="Missing", description="True if the linked file is not found")
has_indirect_missing: BoolProperty(name="Has Indirect Missing", description="True if an indirectly linked blend is missing")
indirect_missing_count: IntProperty(name="Indirect Missing Count", description="Number of indirectly linked blends that are missing")
is_expanded: BoolProperty(name="Expanded", description="Whether this library item is expanded in the UI", default=True)
linked_datablocks: CollectionProperty(type=LinkedDatablockItem, name="Linked Datablocks")
class DynamicLinkManagerProperties(PropertyGroup):
linked_libraries: CollectionProperty(type=LinkedLibraryItem, name="Linked Libraries")
linked_libraries_index: IntProperty(
name="Linked Libraries Index",
description="Index of the selected linked library",
default=0
)
linked_assets_count: IntProperty(
name="Linked Assets Count",
description="Number of linked assets found in scene",
default=0
)
linked_libraries_expanded: BoolProperty(
name="Linked Libraries Expanded",
description="Whether the linked libraries section is expanded",
default=True
)
selected_asset_path: StringProperty(
name="Selected Asset Path",
description="Path to the currently selected asset",
default=""
)
# Addon preferences for search paths
class DynamicLinkManagerPreferences(AddonPreferences):
bl_idname = __package__
search_paths: CollectionProperty(
type=SearchPathItem,
name="Search Paths",
description="Paths to search for missing linked libraries"
)
def draw(self, context):
layout = self.layout
# Search paths section (FMT-style)
box = layout.box()
box.label(text="Default Search Paths for Missing Libraries")
# Add button - right-justified
row = box.row()
row.alignment = 'RIGHT'
row.operator("dlm.add_search_path", text="Add search path", icon='ADD')
# List search paths (FMT-style)
for i, path_item in enumerate(self.search_paths):
row = box.row()
row.prop(path_item, "path", text=f"Search path {i+1}")
# Folder icon for browsing (FMT-style)
row.operator("dlm.browse_search_path", text="", icon='FILE_FOLDER').index = i
# Remove button (FMT-style)
row.operator("dlm.remove_search_path", text="", icon='REMOVE').index = i
class DLM_PT_main_panel(Panel):
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Dynamic Link Manager'
bl_label = "Dynamic Link Manager"
def draw_header(self, context):
layout = self.layout
layout.operator("preferences.addon_show", text="", icon='PREFERENCES').module = __package__
def draw(self, context):
layout = self.layout
props = context.scene.dynamic_link_manager
# Main scan section (FMT-style)
box = layout.box()
box.label(text="Linked Libraries Analysis")
row = box.row()
row.operator("dlm.scan_linked_assets", text="Scan Linked Assets", icon='FILE_REFRESH')
row.label(text=f"({props.linked_assets_count} libraries)")
# Show more detailed info if we have results
if props.linked_assets_count > 0:
# Linked Libraries section with single dropdown (FMT-style)
row = box.row(align=True)
# Dropdown arrow for the entire section
icon = 'DISCLOSURE_TRI_DOWN' if props.linked_libraries_expanded else 'DISCLOSURE_TRI_RIGHT'
row.prop(props, "linked_libraries_expanded", text="", icon=icon, icon_only=True)
# Section header
row.label(text="Linked Libraries:")
row.label(text=f"({props.linked_assets_count} libraries)")
# Only show library details if section is expanded
if props.linked_libraries_expanded:
# FMT-style compact list view
row = box.row()
row.template_list("DYNAMICLINK_UL_library_list", "", props, "linked_libraries", props, "linked_libraries_index")
# Action buttons below the list (FMT-style)
row = box.row()
row.scale_x = 0.3
if props.linked_libraries and 0 <= props.linked_libraries_index < len(props.linked_libraries):
selected_lib = props.linked_libraries[props.linked_libraries_index]
open_op = row.operator("dlm.open_linked_file", text="Open Selected", icon='FILE_BLEND')
open_op.filepath = selected_lib.filepath
else:
row.operator("dlm.open_linked_file", text="Open Selected", icon='FILE_BLEND')
row.scale_x = 0.7
row.operator("dlm.scan_linked_assets", text="Refresh", icon='FILE_REFRESH')
# Show details of selected item (FMT-style info display)
if props.linked_libraries and 0 <= props.linked_libraries_index < len(props.linked_libraries):
selected_lib = props.linked_libraries[props.linked_libraries_index]
info_box = box.box()
info_box.label(text=f"Selected: {selected_lib.name}")
if selected_lib.is_missing:
info_box.label(text="Status: MISSING", icon='ERROR')
elif selected_lib.has_indirect_missing:
info_box.label(text=f"Status: INDIRECT MISSING ({selected_lib.indirect_missing_count} dependencies)", icon='ERROR')
else:
info_box.label(text="Status: OK", icon='FILE_BLEND')
info_box.label(text=f"Path: {selected_lib.filepath}", icon='FILE_FOLDER')
# Search Paths Management (FMT-style) - integrated into Linked Libraries Analysis
if props.linked_assets_count > 0:
# Get preferences for search paths
prefs = context.preferences.addons.get(__package__)
# Search paths list (FMT-style) - Each path gets its own row with folder icon
if prefs and prefs.preferences.search_paths:
for i, path_item in enumerate(prefs.preferences.search_paths):
row = box.row()
row.prop(path_item, "path", text=f"Search path {i+1}")
# Folder icon for browsing (FMT-style)
row.operator("dlm.browse_search_path", text="", icon='FILE_FOLDER').index = i
# Remove button (FMT-style)
row.operator("dlm.remove_search_path", text="", icon='REMOVE').index = i
# Add button (FMT-style) - Just the + button, right-justified
row = box.row()
row.alignment = 'RIGHT'
row.operator("dlm.add_search_path", text="", icon='ADD')
# Main action button (FMT-style)
missing_count = sum(1 for lib in props.linked_libraries if lib.is_missing)
if missing_count > 0:
row = box.row()
row.operator("dlm.fmt_style_find", text="Find libraries in these folders", icon='VIEWZOOM')
# Asset replacement section (FMT-style)
box = layout.box()
box.label(text="Asset Replacement")
obj = context.active_object
if obj:
box.label(text=f"Selected: {obj.name}")
# Check if object itself is linked
if obj.library:
box.label(text=f"Linked from: {obj.library.filepath}")
row = box.row()
row.operator("dlm.replace_linked_asset", text="Replace Asset")
# Check if object's data is linked
elif obj.data and obj.data.library:
box.label(text=f"Data linked from: {obj.data.library.filepath}")
row = box.row()
row.operator("dlm.replace_linked_asset", text="Replace Asset")
# Check if it's a linked armature
elif obj.type == 'ARMATURE' and obj.data and hasattr(obj.data, 'library') and obj.data.library:
box.label(text=f"Armature linked from: {obj.data.library.filepath}")
row = box.row()
row.operator("dlm.replace_linked_asset", text="Replace Asset")
else:
box.label(text="Not a linked asset")
else:
box.label(text="No object selected")
# Settings section (FMT-style)
box = layout.box()
box.label(text="Settings")
row = box.row()
row.prop(props, "selected_asset_path", text="Asset Path")
def register():
bpy.utils.register_class(SearchPathItem)
bpy.utils.register_class(LinkedDatablockItem)
bpy.utils.register_class(DYNAMICLINK_UL_library_list)
bpy.utils.register_class(LinkedLibraryItem)
bpy.utils.register_class(DynamicLinkManagerProperties)
bpy.utils.register_class(DynamicLinkManagerPreferences)
bpy.utils.register_class(DLM_PT_main_panel)
# Register properties to scene
bpy.types.Scene.dynamic_link_manager = bpy.props.PointerProperty(type=DynamicLinkManagerProperties)
def unregister():
# Unregister properties from scene
del bpy.types.Scene.dynamic_link_manager
bpy.utils.unregister_class(DLM_PT_main_panel)
bpy.utils.unregister_class(DynamicLinkManagerPreferences)
bpy.utils.unregister_class(DynamicLinkManagerProperties)
bpy.utils.unregister_class(LinkedLibraryItem)
bpy.utils.unregister_class(DYNAMICLINK_UL_library_list)
bpy.utils.unregister_class(LinkedDatablockItem)
bpy.utils.unregister_class(SearchPathItem)
@@ -0,0 +1,24 @@
# 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.
from .operators import OPERATOR_CLASSES
from .panels import DLM_PT_main_panel, DLM_UL_library_list
from .preferences import DynamicLinkManagerPreferences
from . import properties
PANEL_CLASSES = [DLM_PT_main_panel, DLM_UL_library_list]
CLASSES = (
properties.SearchPathItem,
properties.LinkedDatablockItem,
properties.LinkedLibraryItem,
properties.DynamicLinkManagerProperties,
DynamicLinkManagerPreferences,
DLM_UL_library_list,
DLM_PT_main_panel,
*OPERATOR_CLASSES,
)
__all__ = ["CLASSES", "OPERATOR_CLASSES", "PANEL_CLASSES", "DynamicLinkManagerPreferences", "properties"]
@@ -0,0 +1,694 @@
# 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.
import bpy
import os
from bpy.types import Operator
from bpy.props import StringProperty, IntProperty
ADDON_NAME = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
def _prefs(context):
return context.preferences.addons.get(ADDON_NAME)
class DLM_OT_replace_linked_asset(Operator):
bl_idname = "dlm.replace_linked_asset"
bl_label = "Replace Linked Asset"
bl_description = "Open file browser to replace the linked asset with another file"
bl_options = {"REGISTER", "UNDO"}
filepath: StringProperty(name="File Path", description="Path to the new asset file", subtype="FILE_PATH")
def execute(self, context):
obj = context.active_object
if not obj:
self.report({"ERROR"}, "No object selected")
return {"CANCELLED"}
if getattr(obj, "library", None):
self.report({"INFO"}, f"Object '{obj.name}' is linked from: {obj.library.filepath}")
return {"FINISHED"}
if obj.data and getattr(obj.data, "library", None) and obj.data.library:
self.report({"INFO"}, f"Object '{obj.name}' has linked data from: {obj.data.library.filepath}")
return {"FINISHED"}
if obj.type == "ARMATURE" and obj.data and obj.data.name in bpy.data.armatures:
ad = bpy.data.armatures[obj.data.name]
if getattr(ad, "library", None) and ad.library:
self.report({"INFO"}, f"Armature '{obj.name}' data is linked from: {ad.library.filepath}")
return {"FINISHED"}
self.report({"ERROR"}, "Selected object is not a linked asset")
return {"CANCELLED"}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
class DLM_OT_scan_linked_assets(Operator):
bl_idname = "dlm.scan_linked_assets"
bl_label = "Scan Linked Libraries"
bl_description = "Scan the current file for linked libraries and list their status"
bl_options = {"REGISTER"}
def execute(self, context):
from ..ops import library
return library.scan_linked_assets(context, self.report)
class DLM_OT_find_libraries_in_folders(Operator):
bl_idname = "dlm.find_libraries_in_folders"
bl_label = "Find Libraries in Folders"
bl_description = "Search addon search paths for missing library blend files"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
from ..ops import library
return library.find_libraries_in_folders(context, self.report, ADDON_NAME)
class DLM_OT_open_linked_file(Operator):
bl_idname = "dlm.open_linked_file"
bl_label = "Open Linked File"
bl_description = "Open the selected linked blend file in a new Blender instance"
bl_options = {"REGISTER"}
filepath: StringProperty(name="File Path", default="")
def execute(self, context):
if not self.filepath:
self.report({"ERROR"}, "No file path specified")
return {"CANCELLED"}
try:
bpy.ops.wm.path_open(filepath=self.filepath)
self.report({"INFO"}, f"Opening linked file: {self.filepath}")
except Exception as e:
self.report({"ERROR"}, f"Failed to open linked file: {e}")
return {"CANCELLED"}
return {"FINISHED"}
class DLM_OT_add_search_path(Operator):
bl_idname = "dlm.add_search_path"
bl_label = "Add Search Path"
bl_description = "Add a new folder to the list of search paths for finding libraries"
bl_options = {"REGISTER"}
def execute(self, context):
prefs = _prefs(context)
if prefs:
p = prefs.preferences.search_paths.add()
p.path = "//"
self.report({"INFO"}, f"Added search path: {p.path}")
return {"FINISHED"}
class DLM_OT_remove_search_path(Operator):
bl_idname = "dlm.remove_search_path"
bl_label = "Remove Search Path"
bl_description = "Remove the selected search path from the list"
bl_options = {"REGISTER"}
index: IntProperty(name="Index", default=0)
def execute(self, context):
prefs = _prefs(context)
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
prefs.preferences.search_paths.remove(self.index)
self.report({"INFO"}, f"Removed search path at index {self.index}")
return {"FINISHED"}
class DLM_OT_attempt_relink(Operator):
bl_idname = "dlm.attempt_relink"
bl_label = "Attempt Relink"
bl_description = "Try to relink missing libraries using the configured search paths"
bl_options = {"REGISTER"}
def execute(self, context):
from ..ops import library
return library.attempt_relink(context, self.report, ADDON_NAME)
class DLM_OT_browse_search_path(Operator):
bl_idname = "dlm.browse_search_path"
bl_label = "Browse Search Path"
bl_description = "Browse to set the folder for the selected search path"
bl_options = {"REGISTER"}
index: IntProperty(name="Index", default=0)
filepath: StringProperty(name="Search Path", subtype="DIR_PATH")
def execute(self, context):
prefs = _prefs(context)
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
prefs.preferences.search_paths[self.index].path = self.filepath
self.report({"INFO"}, f"Updated search path {self.index + 1}: {self.filepath}")
return {"FINISHED"}
def invoke(self, context, event):
prefs = _prefs(context)
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
self.filepath = prefs.preferences.search_paths[self.index].path
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
class DLM_OT_reload_libraries(Operator):
bl_idname = "dlm.reload_libraries"
bl_label = "Reload Libraries"
bl_description = "Reload all linked libraries (or fallback manual reload)"
bl_options = {"REGISTER"}
def execute(self, context):
try:
bpy.ops.outliner.lib_operation(type="RELOAD")
self.report({"INFO"}, "Library reload operation completed")
except Exception:
try:
for lib in bpy.data.libraries:
if lib.filepath and os.path.exists(bpy.path.abspath(lib.filepath)):
lib.reload()
self.report({"INFO"}, "Libraries reloaded manually")
except Exception as e:
self.report({"ERROR"}, f"Failed to reload libraries: {e}")
return {"CANCELLED"}
return {"FINISHED"}
class DLM_OT_make_paths_relative(Operator):
bl_idname = "dlm.make_paths_relative"
bl_label = "Make Paths Relative"
bl_description = "Convert all internal file paths to relative"
bl_options = {"REGISTER"}
def execute(self, context):
try:
bpy.ops.file.make_paths_relative()
self.report({"INFO"}, "All file paths made relative")
except Exception as e:
self.report({"ERROR"}, f"Failed to make paths relative: {e}")
return {"CANCELLED"}
return {"FINISHED"}
class DLM_OT_make_paths_absolute(Operator):
bl_idname = "dlm.make_paths_absolute"
bl_label = "Make Paths Absolute"
bl_description = "Convert all internal file paths to absolute"
bl_options = {"REGISTER"}
def execute(self, context):
try:
bpy.ops.file.make_paths_absolute()
self.report({"INFO"}, "All file paths made absolute")
except Exception as e:
self.report({"ERROR"}, f"Failed to make paths absolute: {e}")
return {"CANCELLED"}
return {"FINISHED"}
class DLM_OT_relocate_single_library(Operator):
bl_idname = "dlm.relocate_single_library"
bl_label = "Relocate Library"
bl_description = "Point the selected library to a new blend file and reload"
bl_options = {"REGISTER", "UNDO"}
target_filepath: StringProperty(name="Current Library Path", default="")
filepath: StringProperty(name="New Library File", subtype="FILE_PATH", default="")
def execute(self, context):
if not self.target_filepath or not self.filepath:
self.report({"ERROR"}, "No target or new file specified")
return {"CANCELLED"}
abs_match = bpy.path.abspath(self.target_filepath) if self.target_filepath else ""
library = None
for lib in bpy.data.libraries:
try:
if lib.filepath and bpy.path.abspath(lib.filepath) == abs_match:
library = lib
break
except Exception:
if lib.filepath == self.target_filepath:
library = lib
break
if not library:
self.report({"ERROR"}, "Could not resolve the selected library")
return {"CANCELLED"}
try:
library.filepath = self.filepath
library.reload()
self.report({"INFO"}, f"Relocated to: {self.filepath}")
except Exception as e:
self.report({"ERROR"}, f"Failed to relocate: {e}")
return {"CANCELLED"}
return {"FINISHED"}
def invoke(self, context, event):
if self.target_filepath:
try:
self.filepath = bpy.path.abspath(self.target_filepath)
except Exception:
self.filepath = self.target_filepath
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
def _get_migrator_pair(context):
"""Return (orig, rep) from scene props (manual or automatic). (None, None) if invalid."""
from ..ops.migrator import get_pair_manual, get_pair_automatic
props = getattr(context.scene, "dynamic_link_manager", None)
if not props:
return None, None
use_auto = getattr(props, "migrator_mode", False)
orig, rep = get_pair_automatic(context) if use_auto else get_pair_manual(context)
return orig, rep
class DLM_OT_migrator_copy_attributes(Operator):
bl_idname = "dlm.migrator_copy_attributes"
bl_label = "CopyAttr"
bl_description = "Copy object and armature attributes from original to replacement character"
bl_icon = "COPY_ID"
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 (set Original/Replacement or enable Automatic).")
return {"CANCELLED"}
try:
from ..ops.migrator import run_copy_attr
run_copy_attr(orig, rep)
self.report({"INFO"}, "Copy attributes done.")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
class DLM_OT_migrator_migrate_nla(Operator):
bl_idname = "dlm.migrator_migrate_nla"
bl_label = "MigNLA"
bl_description = "Migrate NLA tracks and strips from original to replacement character"
bl_icon = "NLA"
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.migrator import run_mig_nla
run_mig_nla(orig, rep, report=self.report)
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
class DLM_OT_migrator_custom_properties(Operator):
bl_idname = "dlm.migrator_custom_properties"
bl_label = "MigCustProps"
bl_description = "Copy custom properties from original to replacement character"
bl_icon = "PROPERTIES"
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.migrator import run_mig_cust_props
run_mig_cust_props(orig, rep)
self.report({"INFO"}, "Custom properties done.")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
class DLM_OT_migrator_bone_constraints(Operator):
bl_idname = "dlm.migrator_bone_constraints"
bl_label = "MigBoneConst"
bl_description = "Migrate bone constraints from original to replacement armature"
bl_icon = "CONSTRAINT_BONE"
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.migrator import run_mig_bone_const
orig_to_rep = {orig: rep}
run_mig_bone_const(orig, rep, orig_to_rep)
self.report({"INFO"}, "Bone constraints done.")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
class DLM_OT_migrator_retarget_relations(Operator):
bl_idname = "dlm.migrator_retarget_relations"
bl_label = "RetargRelatives"
bl_description = "Retarget parent/child and other relations to the replacement character"
bl_icon = "ORIENTATION_PARENT"
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.migrator import run_retarg_relatives
from ..utils import descendants
rep_descendants = descendants(rep)
orig_to_rep = {orig: rep}
run_retarg_relatives(orig, rep, rep_descendants, orig_to_rep)
self.report({"INFO"}, "Retarget relations done.")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
class DLM_OT_migrator_basebody_shapekeys(Operator):
bl_idname = "dlm.migrator_basebody_shapekeys"
bl_label = "MigBBodyShapeKeys"
bl_description = "Migrate base body mesh shape key values from original to replacement"
bl_icon = "SHAPEKEY_DATA"
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.migrator import run_mig_bbody_shapekeys
from ..utils import descendants
rep_descendants = descendants(rep)
run_mig_bbody_shapekeys(orig, rep, rep_descendants, context)
self.report({"INFO"}, "Migrate BaseBody shapekeys done.")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, str(e))
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"
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]}")
return {"CANCELLED"}
self.report({"INFO"}, "Migration complete.")
return {"FINISHED"}
class DLM_OT_picker_original_character(Operator):
bl_idname = "dlm.picker_original_character"
bl_label = "Pick Original"
bl_description = "Set the original character armature from the active object"
bl_options = {"REGISTER"}
def execute(self, context):
obj = context.active_object
if not obj or obj.type != "ARMATURE":
self.report({"WARNING"}, "Select an armature")
return {"CANCELLED"}
context.scene.dynamic_link_manager.original_character = obj
self.report({"INFO"}, f"Original: {obj.name}")
return {"FINISHED"}
class DLM_OT_picker_replacement_character(Operator):
bl_idname = "dlm.picker_replacement_character"
bl_label = "Pick Replacement"
bl_description = "Set the replacement character armature from the active object"
bl_options = {"REGISTER"}
def execute(self, context):
obj = context.active_object
if not obj or obj.type != "ARMATURE":
self.report({"WARNING"}, "Select an armature")
return {"CANCELLED"}
context.scene.dynamic_link_manager.replacement_character = obj
self.report({"INFO"}, f"Replacement: {obj.name}")
return {"FINISHED"}
def _tweak_poll(context):
orig, rep = _get_migrator_pair(context)
return orig is not None and rep is not None
class DLM_OT_tweak_add_arm(Operator):
bl_idname = "dlm.tweak_add_arm"
bl_label = "Add Arm Tweaks"
bl_description = "Add tweak bone constraints to arm bones on the replacement character"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return _tweak_poll(context)
def execute(self, context):
orig, rep = _get_migrator_pair(context)
from ..ops import tweak_tools
tweak_tools.add_tweak_constraints(orig, rep, "arm")
self.report({"INFO"}, "Arm tweak constraints added.")
return {"FINISHED"}
class DLM_OT_tweak_remove_arm(Operator):
bl_idname = "dlm.tweak_remove_arm"
bl_label = "Remove Arm Tweaks"
bl_description = "Remove arm tweak constraints from the replacement character"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return _tweak_poll(context)
def execute(self, context):
orig, rep = _get_migrator_pair(context)
from ..ops import tweak_tools
n = tweak_tools.remove_tweak_constraints(orig, rep, "arm")
self.report({"INFO"}, f"Removed {n} arm tweak constraints.")
return {"FINISHED"}
class DLM_OT_tweak_bake_arm(Operator):
bl_idname = "dlm.tweak_bake_arm"
bl_label = "Bake Arm Tweaks"
bl_description = "Bake arm tweak constraints to keyframes and optionally remove constraints"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return _tweak_poll(context)
def execute(self, context):
orig, rep = _get_migrator_pair(context)
props = context.scene.dynamic_link_manager
from ..ops import tweak_tools
ok, msg = tweak_tools.bake_tweak_constraints(
context, orig, rep, "arm",
getattr(props, "tweak_nla_track_name", "") or "",
getattr(props, "tweak_bake_post_clean", False),
)
if ok:
self.report({"INFO"}, msg)
return {"FINISHED"}
self.report({"ERROR"}, msg)
return {"CANCELLED"}
class DLM_OT_tweak_add_leg(Operator):
bl_idname = "dlm.tweak_add_leg"
bl_label = "Add Leg Tweaks"
bl_description = "Add tweak bone constraints to leg bones on the replacement character"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return _tweak_poll(context)
def execute(self, context):
orig, rep = _get_migrator_pair(context)
from ..ops import tweak_tools
tweak_tools.add_tweak_constraints(orig, rep, "leg")
self.report({"INFO"}, "Leg tweak constraints added.")
return {"FINISHED"}
class DLM_OT_tweak_remove_leg(Operator):
bl_idname = "dlm.tweak_remove_leg"
bl_label = "Remove Leg Tweaks"
bl_description = "Remove leg tweak constraints from the replacement character"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return _tweak_poll(context)
def execute(self, context):
orig, rep = _get_migrator_pair(context)
from ..ops import tweak_tools
n = tweak_tools.remove_tweak_constraints(orig, rep, "leg")
self.report({"INFO"}, f"Removed {n} leg tweak constraints.")
return {"FINISHED"}
class DLM_OT_tweak_bake_leg(Operator):
bl_idname = "dlm.tweak_bake_leg"
bl_label = "Bake Leg Tweaks"
bl_description = "Bake leg tweak constraints to keyframes and optionally remove constraints"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return _tweak_poll(context)
def execute(self, context):
orig, rep = _get_migrator_pair(context)
props = context.scene.dynamic_link_manager
from ..ops import tweak_tools
ok, msg = tweak_tools.bake_tweak_constraints(
context, orig, rep, "leg",
getattr(props, "tweak_nla_track_name", "") or "",
getattr(props, "tweak_bake_post_clean", False),
)
if ok:
self.report({"INFO"}, msg)
return {"FINISHED"}
self.report({"ERROR"}, msg)
return {"CANCELLED"}
class DLM_OT_tweak_add_both(Operator):
bl_idname = "dlm.tweak_add_both"
bl_label = "Add Arm & Leg Tweaks"
bl_description = "Add tweak bone constraints to both arm and leg bones"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return _tweak_poll(context)
def execute(self, context):
orig, rep = _get_migrator_pair(context)
from ..ops import tweak_tools
tweak_tools.add_tweak_constraints(orig, rep, "both")
self.report({"INFO"}, "Arm & leg tweak constraints added.")
return {"FINISHED"}
class DLM_OT_tweak_remove_both(Operator):
bl_idname = "dlm.tweak_remove_both"
bl_label = "Remove Arm & Leg Tweaks"
bl_description = "Remove all arm and leg tweak constraints from the replacement character"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return _tweak_poll(context)
def execute(self, context):
orig, rep = _get_migrator_pair(context)
from ..ops import tweak_tools
n = tweak_tools.remove_tweak_constraints(orig, rep, "both")
self.report({"INFO"}, f"Removed {n} tweak constraints.")
return {"FINISHED"}
class DLM_OT_tweak_bake_both(Operator):
bl_idname = "dlm.tweak_bake_both"
bl_label = "Bake Arm & Leg Tweaks"
bl_description = "Bake all arm and leg tweak constraints to keyframes and optionally remove constraints"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return _tweak_poll(context)
def execute(self, context):
orig, rep = _get_migrator_pair(context)
props = context.scene.dynamic_link_manager
from ..ops import tweak_tools
ok, msg = tweak_tools.bake_tweak_constraints(
context, orig, rep, "both",
getattr(props, "tweak_nla_track_name", "") or "",
getattr(props, "tweak_bake_post_clean", False),
)
if ok:
self.report({"INFO"}, msg)
return {"FINISHED"}
self.report({"ERROR"}, msg)
return {"CANCELLED"}
OPERATOR_CLASSES = [
DLM_OT_replace_linked_asset,
DLM_OT_scan_linked_assets,
DLM_OT_find_libraries_in_folders,
DLM_OT_open_linked_file,
DLM_OT_add_search_path,
DLM_OT_remove_search_path,
DLM_OT_browse_search_path,
DLM_OT_attempt_relink,
DLM_OT_reload_libraries,
DLM_OT_make_paths_relative,
DLM_OT_make_paths_absolute,
DLM_OT_relocate_single_library,
DLM_OT_run_character_migration,
DLM_OT_picker_original_character,
DLM_OT_picker_replacement_character,
DLM_OT_migrator_copy_attributes,
DLM_OT_migrator_migrate_nla,
DLM_OT_migrator_custom_properties,
DLM_OT_migrator_bone_constraints,
DLM_OT_migrator_retarget_relations,
DLM_OT_migrator_basebody_shapekeys,
DLM_OT_tweak_add_arm,
DLM_OT_tweak_remove_arm,
DLM_OT_tweak_bake_arm,
DLM_OT_tweak_add_leg,
DLM_OT_tweak_remove_leg,
DLM_OT_tweak_bake_leg,
DLM_OT_tweak_add_both,
DLM_OT_tweak_remove_both,
DLM_OT_tweak_bake_both,
]
@@ -0,0 +1,164 @@
# 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.
import bpy
from bpy.types import Panel, UIList
from . import properties
class DLM_UL_library_list(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
if self.layout_type in {"DEFAULT", "COMPACT"}:
layout.scale_x = 0.4
layout.label(text=item.name)
layout.scale_x = 0.3
if item.is_missing:
layout.label(text="MISSING", icon="ERROR")
elif item.is_indirect:
layout.label(text="INDIRECT", icon="INFO")
else:
layout.label(text="OK", icon="FILE_BLEND")
layout.scale_x = 0.3
path_text = item.filepath or ""
if path_text.startswith("\\\\"):
parts = path_text.split("\\")
short_path = f"\\\\{parts[2]}\\" if len(parts) >= 3 else "\\\\ (network)"
elif len(path_text) >= 2 and path_text[1] == ":":
short_path = f"{path_text[:2]}\\"
elif path_text.startswith("//"):
short_path = "// (relative)"
else:
short_path = path_text[:15] + "..." if len(path_text) > 15 else path_text
layout.label(text=short_path, icon="FILE_FOLDER")
elif self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", icon="FILE_BLEND")
def _get_short_path(filepath):
if not filepath:
return "Unknown"
if filepath.startswith("//"):
return "// (relative)"
if filepath.startswith("\\\\"):
parts = filepath.split("\\")
return f"\\\\{parts[2]}\\" if len(parts) >= 3 else "\\\\ (network)"
if len(filepath) >= 2 and filepath[1] == ":":
return f"{filepath[:2]}\\"
if filepath.startswith("/"):
parts = filepath.split("/")
return f"/{parts[1]}/" if len(parts) >= 2 else "/ (root)"
return "Unknown"
class DLM_PT_main_panel(Panel):
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Dynamic Link Manager"
bl_label = "Dynamic Link Manager"
def draw_header(self, context):
layout = self.layout
layout.operator("preferences.addon_show", text="", icon="PREFERENCES").module = __package__.rsplit(".", 1)[0]
def draw(self, context):
layout = self.layout
props = context.scene.dynamic_link_manager
# Path management
row = layout.row()
row.operator("dlm.make_paths_relative", text="Make Paths Relative", icon="FILE_PARENT")
row.operator("dlm.make_paths_absolute", text="Make Paths Absolute", icon="FILE_FOLDER")
# CharMig section (placeholder; full UI in implementation step 5)
box = layout.box()
box.label(text="Character Migrator")
row = box.row()
row.prop(props, "migrator_mode", text="Automatic pair discovery")
row = box.row()
row.prop(props, "original_character", text="Original")
row.operator("dlm.picker_original_character", text="", icon="EYEDROPPER")
row = box.row()
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 = 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")
row.operator("dlm.migrator_custom_properties", text="MigCustProps", icon="PROPERTIES")
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")
# Linked Libraries: header row (always), main box only when expanded
missing_count = sum(1 for lib in props.linked_libraries if lib.is_missing)
section_icon = "DISCLOSURE_TRI_DOWN" if props.linked_libraries_section_expanded else "DISCLOSURE_TRI_RIGHT"
row = layout.row(align=True)
row.prop(props, "linked_libraries_section_expanded", text="", icon=section_icon, icon_only=True)
row.label(text="Linked Libraries Analysis")
if missing_count > 0:
row.label(text=f"({props.linked_assets_count} libs, {missing_count} missing)", icon="ERROR")
else:
row.label(text=f"({props.linked_assets_count} libraries)")
if props.linked_libraries_section_expanded:
box = layout.box()
row = box.row()
row.operator("dlm.scan_linked_assets", text="Scan Linked Assets", icon="FILE_REFRESH")
if props.linked_assets_count > 0:
row = box.row()
row.template_list("DLM_UL_library_list", "", props, "linked_libraries", props, "linked_libraries_index")
row = box.row()
row.operator("dlm.reload_libraries", text="Reload Libraries", icon="FILE_REFRESH")
prefs = context.preferences.addons.get(__package__.rsplit(".", 1)[0])
if prefs and prefs.preferences.search_paths:
for i, path_item in enumerate(prefs.preferences.search_paths):
row = box.row()
row.prop(path_item, "path", text=f"Search path {i+1}")
row.operator("dlm.browse_search_path", text="", icon="FILE_FOLDER").index = i
row.operator("dlm.remove_search_path", text="", icon="REMOVE").index = i
row = box.row()
row.alignment = "RIGHT"
row.operator("dlm.add_search_path", text="Add search path", icon="ADD")
if missing_count > 0:
row = box.row()
row.operator("dlm.find_libraries_in_folders", text="Find libraries in these folders", icon="VIEWZOOM")
if props.linked_libraries and 0 <= props.linked_libraries_index < len(props.linked_libraries):
selected_lib = props.linked_libraries[props.linked_libraries_index]
info_box = box.box()
if selected_lib.is_missing:
info_box.alert = True
info_box.label(text=f"Selected: {selected_lib.name}")
if selected_lib.is_missing:
info_box.label(text="Status: MISSING", icon="ERROR")
elif selected_lib.is_indirect:
info_box.label(text="Status: INDIRECT", icon="INFO")
else:
info_box.label(text="Status: OK", icon="FILE_BLEND")
info_box.label(text=f"Path: {selected_lib.filepath}", icon="FILE_FOLDER")
info_box.operator("dlm.open_linked_file", text="Open Blend", icon="FILE_BLEND").filepath = selected_lib.filepath
op = info_box.operator("dlm.relocate_single_library", text="Relocate Library", icon="FILE_FOLDER")
op.target_filepath = selected_lib.filepath
@@ -0,0 +1,32 @@
# 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.
import bpy
from bpy.types import AddonPreferences
from bpy.props import StringProperty, CollectionProperty
from . import properties
class DynamicLinkManagerPreferences(AddonPreferences):
bl_idname = __package__.rsplit(".", 1)[0]
search_paths: CollectionProperty(
type=properties.SearchPathItem,
name="Search Paths",
description="Paths to search for missing linked libraries",
)
def draw(self, context):
layout = self.layout
box = layout.box()
box.label(text="Default Search Paths for Missing Libraries")
row = box.row()
row.alignment = "RIGHT"
row.operator("dlm.add_search_path", text="Add search path", icon="ADD")
for i, path_item in enumerate(self.search_paths):
row = box.row()
row.prop(path_item, "path", text=f"Search path {i+1}")
row.operator("dlm.browse_search_path", text="", icon="FILE_FOLDER").index = i
row.operator("dlm.remove_search_path", text="", icon="REMOVE").index = i
@@ -0,0 +1,73 @@
# 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.
import bpy
from bpy.types import PropertyGroup
from bpy.props import IntProperty, StringProperty, BoolProperty, CollectionProperty, PointerProperty
class SearchPathItem(PropertyGroup):
path: StringProperty(
name="Search Path",
description="Path to search for missing linked libraries",
subtype="DIR_PATH",
)
class LinkedDatablockItem(PropertyGroup):
name: StringProperty(name="Name", description="Name of the linked datablock")
type: StringProperty(name="Type", description="Type of the linked datablock")
class LinkedLibraryItem(PropertyGroup):
filepath: StringProperty(name="File Path", description="Path to the linked .blend file")
name: StringProperty(name="Name", description="Name of the linked .blend file")
is_missing: BoolProperty(name="Missing", description="True if the linked file is not found")
is_indirect: BoolProperty(name="Is Indirect", description="True if this is an indirectly linked library")
is_expanded: BoolProperty(name="Expanded", default=True)
linked_datablocks: CollectionProperty(type=LinkedDatablockItem, name="Linked Datablocks")
class DynamicLinkManagerProperties(PropertyGroup):
linked_libraries: CollectionProperty(type=LinkedLibraryItem, name="Linked Libraries")
linked_libraries_index: IntProperty(name="Linked Libraries Index", default=0)
linked_assets_count: IntProperty(name="Linked Assets Count", default=0)
linked_libraries_section_expanded: BoolProperty(
name="Linked Libraries Analysis Expanded",
description="Show or hide the Linked Libraries Analysis section",
default=False,
)
selected_asset_path: StringProperty(name="Selected Asset Path", default="")
# Character migrator (manual mode)
migrator_mode: BoolProperty(
name="Automatic",
description="Automatic: discover pair by Name_Rigify / Name_Rigify.001. Manual: use fields below",
default=False,
)
original_character: PointerProperty(
name="Original Character",
description="Armature to migrate from",
type=bpy.types.Object,
poll=lambda self, obj: obj and obj.type == "ARMATURE",
)
replacement_character: PointerProperty(
name="Replacement Character",
description="Armature to migrate to",
type=bpy.types.Object,
poll=lambda self, obj: obj and obj.type == "ARMATURE",
)
# Tweak tools (bake frame range and post-clean)
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",
default="",
)
tweak_bake_post_clean: BoolProperty(
name="Post-clean after bake",
description="Run action clean keyframes and graph decimate (error 0.001) after baking",
default=False,
)
@@ -0,0 +1,39 @@
# 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.
"""Shared helpers for Dynamic Link Manager."""
import bpy
def descendants(armature):
"""Return a set of objects whose parent chain leads to the given armature."""
out = set()
for ob in bpy.data.objects:
p = ob.parent
while p:
if p == armature:
out.add(ob)
break
p = p.parent
return out
def collection_containing_armature(armature):
"""
Return a collection that contains the armature (for linked character context).
Prefers a collection whose name matches the character base (e.g. "Steve" for Steve_Rigify).
"""
if not armature or armature.name not in bpy.data.objects:
return None
colls = getattr(armature, "users_collection", []) or []
if not colls:
return None
name = armature.name
base = name.replace("_Rigify", "").replace(".001", "").rstrip("0123456789.")
for c in colls:
if c.name == base or base in c.name or c.name in name:
return c
return colls[0]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long