2026-03-11_1
This commit is contained in:
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
-1
@@ -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]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
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
Reference in New Issue
Block a user