224 lines
7.5 KiB
Python
224 lines
7.5 KiB
Python
bl_info = {
|
|
"name": "Relink Broken Linked Object or Collection",
|
|
"author": "ChatGPT + Ryan",
|
|
"version": (1, 4),
|
|
"blender": (4, 0, 0),
|
|
"location": "3D View > Sidebar > Relink Tab",
|
|
"description": "Fix broken links by relinking objects or collections from a new .blend file",
|
|
"category": "Object",
|
|
}
|
|
|
|
import bpy
|
|
from bpy.types import Operator, Panel, PropertyGroup
|
|
from bpy.props import StringProperty, PointerProperty, BoolProperty
|
|
from bpy_extras.io_utils import ImportHelper
|
|
import difflib
|
|
|
|
|
|
def get_blend_objects_and_collections(filepath):
|
|
try:
|
|
with bpy.data.libraries.load(filepath, link=True) as (data_from, _):
|
|
objects = [(name, f"[OBJ] {name}", "") for name in data_from.objects]
|
|
collections = [(name, f"[COL] {name}", "") for name in data_from.collections]
|
|
return objects, collections
|
|
except Exception as e:
|
|
print(f"Error reading blend: {e}")
|
|
return [], []
|
|
|
|
|
|
def is_broken_link(obj):
|
|
# Typical check for broken linked empty object
|
|
return obj.library and obj.type == 'EMPTY' and obj.data is None
|
|
|
|
|
|
class SwapLinkProps(PropertyGroup):
|
|
old_object: StringProperty(name="Old Object")
|
|
blend_path: StringProperty(name="Blend File", subtype='FILE_PATH')
|
|
new_object: StringProperty(name="New Object")
|
|
new_collection: StringProperty(name="New Collection")
|
|
use_collection: BoolProperty(name="Use Collection", default=False)
|
|
|
|
|
|
class VIEW3D_OT_select_blend_file(Operator, ImportHelper):
|
|
bl_idname = "view3d.select_blend_file"
|
|
bl_label = "Select .blend File"
|
|
filename_ext = ".blend"
|
|
filter_glob: StringProperty(default="*.blend", options={'HIDDEN'})
|
|
|
|
def execute(self, context):
|
|
props = context.scene.swap_link_props
|
|
props.blend_path = self.filepath
|
|
|
|
obj_items, col_items = get_blend_objects_and_collections(self.filepath)
|
|
|
|
if not obj_items and not col_items:
|
|
self.report({'ERROR'}, "No objects or collections found in file")
|
|
return {'CANCELLED'}
|
|
|
|
object_names = [item[0] for item in obj_items]
|
|
collection_names = [item[0] for item in col_items]
|
|
|
|
# Auto-pick close match
|
|
if props.old_object:
|
|
close_obj = difflib.get_close_matches(props.old_object, object_names, 1)
|
|
close_col = difflib.get_close_matches(props.old_object, collection_names, 1)
|
|
if close_obj:
|
|
props.new_object = close_obj[0]
|
|
props.use_collection = False
|
|
elif close_col:
|
|
props.new_collection = close_col[0]
|
|
props.use_collection = True
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class VIEW3D_OT_swap_linked_asset(Operator):
|
|
bl_idname = "view3d.swap_linked_asset"
|
|
bl_label = "Swap Linked Asset"
|
|
def execute(self, context):
|
|
props = context.scene.swap_link_props
|
|
old_name = props.old_object
|
|
path = props.blend_path
|
|
|
|
if props.use_collection:
|
|
name = props.new_collection
|
|
if not name:
|
|
self.report({'ERROR'}, "No collection selected")
|
|
return {'CANCELLED'}
|
|
|
|
with bpy.data.libraries.load(path, link=True) as (data_from, data_to):
|
|
if name in data_from.collections:
|
|
data_to.collections = [name]
|
|
|
|
new_col = bpy.data.collections.get(name)
|
|
if not new_col:
|
|
self.report({'ERROR'}, "Failed to load collection")
|
|
return {'CANCELLED'}
|
|
|
|
# Make a copy list of all old objects to replace
|
|
old_objs = [obj for obj in bpy.data.objects if obj.name == old_name and obj.library]
|
|
|
|
replaced = 0
|
|
for obj in old_objs:
|
|
loc = obj.location.copy()
|
|
rot = obj.rotation_euler.copy()
|
|
scale = obj.scale.copy()
|
|
user_cols = list(obj.users_collection)
|
|
|
|
# Unlink and remove old object
|
|
for col in user_cols:
|
|
col.objects.unlink(obj)
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
|
|
# Create new collection instance
|
|
inst = bpy.data.objects.new(f"{name}_instance", None)
|
|
inst.instance_type = 'COLLECTION'
|
|
inst.instance_collection = new_col
|
|
inst.location = loc
|
|
inst.rotation_euler = rot
|
|
inst.scale = scale
|
|
|
|
for col in user_cols:
|
|
col.objects.link(inst)
|
|
replaced += 1
|
|
|
|
# Force depsgraph update (optional)
|
|
context.view_layer.update()
|
|
|
|
self.report({'INFO'}, f"Replaced with collection ({replaced})")
|
|
return {'FINISHED'}
|
|
|
|
else:
|
|
name = props.new_object
|
|
if not name:
|
|
self.report({'ERROR'}, "No object selected")
|
|
return {'CANCELLED'}
|
|
|
|
with bpy.data.libraries.load(path, link=True) as (data_from, data_to):
|
|
if name in data_from.objects:
|
|
data_to.objects = [name]
|
|
|
|
new_obj = bpy.data.objects.get(name)
|
|
if not new_obj:
|
|
self.report({'ERROR'}, "Failed to load object")
|
|
return {'CANCELLED'}
|
|
|
|
old_objs = [obj for obj in bpy.data.objects if obj.name == old_name and obj.library]
|
|
|
|
replaced = 0
|
|
for obj in old_objs:
|
|
loc = obj.location.copy()
|
|
rot = obj.rotation_euler.copy()
|
|
scale = obj.scale.copy()
|
|
user_cols = list(obj.users_collection)
|
|
|
|
# Unlink and remove old object
|
|
for col in user_cols:
|
|
col.objects.unlink(obj)
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
|
|
# Copy and link new object
|
|
inst = new_obj.copy()
|
|
inst.location = loc
|
|
inst.rotation_euler = rot
|
|
inst.scale = scale
|
|
for col in user_cols:
|
|
col.objects.link(inst)
|
|
|
|
replaced += 1
|
|
|
|
context.view_layer.update()
|
|
|
|
self.report({'INFO'}, f"Replaced with object ({replaced})")
|
|
return {'FINISHED'}
|
|
|
|
|
|
class VIEW3D_PT_swap_panel(Panel):
|
|
bl_label = "Relink Broken Asset"
|
|
bl_idname = "VIEW3D_PT_swap_panel"
|
|
bl_space_type = 'VIEW_3D'
|
|
bl_region_type = 'UI'
|
|
bl_category = 'Relink'
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
props = context.scene.swap_link_props
|
|
|
|
broken_objs = [obj for obj in bpy.data.objects if is_broken_link(obj)]
|
|
if broken_objs:
|
|
layout.label(text="Broken Linked Objects:")
|
|
layout.prop_search(props, "old_object", bpy.data, "objects", text="")
|
|
else:
|
|
layout.label(text="No broken linked objects.")
|
|
|
|
layout.operator("view3d.select_blend_file", text="Select .blend File")
|
|
layout.prop(props, "use_collection")
|
|
if props.use_collection:
|
|
layout.prop(props, "new_collection")
|
|
else:
|
|
layout.prop(props, "new_object")
|
|
layout.operator("view3d.swap_linked_asset", text="Swap Asset")
|
|
|
|
|
|
classes = (
|
|
SwapLinkProps,
|
|
VIEW3D_OT_select_blend_file,
|
|
VIEW3D_OT_swap_linked_asset,
|
|
VIEW3D_PT_swap_panel,
|
|
)
|
|
|
|
|
|
def register():
|
|
for cls in classes:
|
|
bpy.utils.register_class(cls)
|
|
bpy.types.Scene.swap_link_props = PointerProperty(type=SwapLinkProps)
|
|
|
|
|
|
def unregister():
|
|
for cls in reversed(classes):
|
|
bpy.utils.unregister_class(cls)
|
|
del bpy.types.Scene.swap_link_props
|
|
|
|
|
|
if __name__ == "__main__":
|
|
register()
|